Skip to main content

Command Palette

Search for a command to run...

Shopify Theme App Extensions in 2026: The Architecture That Survived Black Friday

Updated
10 min read

Shopify Theme App Extensions in 2026: The Architecture That Survived Black Friday

Series: Shopify Type: Tutorial Meta Description: Build Shopify Theme App Extensions that handle massive traffic spikes. Real architecture with caching, edge rendering, and strategies that kept a production app alive through Black Friday 2025. Keywords: Shopify Theme App Extension, Black Friday traffic, edge rendering, Shopify app architecture, storefront performance Word Count Target: 2200 Published: Draft — NOT for publication


Black Friday 2025: The Stress Test

On November 28, 2025, our Shopify app — a social proof notification tool that displays recent purchases and reviews on merchant storefronts — served 4.7 million page impressions across 1,200 active stores. Peak traffic hit 14,300 requests per second at 11:47 AM EST. The app did not go down. Average response time stayed under 200ms.

This was not luck. It was the result of rebuilding our storefront rendering architecture around Shopify Theme App Extensions, edge caching, and a rendering strategy designed to absorb traffic spikes that would kill a traditional app setup.

Here is exactly how we built it.

Why Theme App Extensions Matter

Before 2025, Shopify apps rendered storefront content by injecting JavaScript tags directly into merchant themes. This approach had three fatal flaws:

  1. Single point of failure. If your server went down, every merchant's storefront showed broken content or loading spinners.
  2. Render-blocking. Your script had to load and execute before content appeared, adding seconds to page load.
  3. No caching control. Every page load hit your server because the script was dynamic.

Theme App Extensions solve all three problems. They run inside Shopify's infrastructure, they render server-side using Liquid, and Shopify automatically caches static assets on their CDN. Your app server only gets hit when data changes, not on every page view.

Architecture Overview

Our architecture has three layers:

Merchant Storefront
    |
    v
Shopify CDN (Theme App Extension — Liquid + cached assets)
    |
    v (only when cache misses or data updates)
Edge Proxy (Cloudflare Workers)
    |
    v
Origin API (Laravel app)

The key insight: most requests never reach our origin server. The Shopify CDN serves the static Liquid template and cached assets. Cloudflare Workers handle API calls for dynamic data with edge caching. Our Laravel origin server only processes data updates and cache invalidations.

Building the Theme App Extension

Extension Structure

shopify app generate extension --type theme_app_extension --name social-proof

The extension directory structure:

extensions/social-proof/
  blocks/
    notification_bar.liquid
    popup_widget.liquid
  snippets/
    notification_render.liquid
    popup_render.liquid
  assets/
    social-proof.css
    social-proof.js

The Notification Bar Block

This block renders a notification bar at the top of the product page showing recent purchases. It uses Liquid for the static structure and a custom JavaScript element for dynamic content.

{%- schema -%}
{
  "name": "Social Proof Bar",
  "target": "section",
  "settings": [
    {
      "type": "text",
      "id": "heading",
      "label": "Heading text",
      "default": "Recently Purchased"
    },
    {
      "type": "range",
      "id": "max_notifications",
      "label": "Max notifications to show",
      "min": 1,
      "max": 10,
      "default": 5
    },
    {
      "type": "select",
      "id": "position",
      "label": "Position",
      "options": [
        { "value": "top", "label": "Top of page" },
        { "value": "bottom", "label": "Bottom of page" }
      ],
      "default": "top"
    },
    {
      "type": "color",
      "id": "bg_color",
      "label": "Background color",
      "default": "#1a1a2e"
    },
    {
      "type": "color",
      "id": "text_color",
      "label": "Text color",
      "default": "#ffffff"
    },
    {
      "type": "checkbox",
      "id": "show_avatar",
      "label": "Show customer avatar",
      "default": true
    },
    {
      "type": "checkbox",
      "id": "show_time",
      "label": "Show time ago",
      "default": true
    }
  ]
}
{%- endschema -%}

<div class="social-proof-bar"
     data-role="social-proof"
     data-shop-id="{{ shop.id }}"
     data-product-id="{{ product.id }}"
     data-max-items="{{ block.settings.max_notifications }}"
     data-api-base="{{ app.metafields.social_proof.api_endpoint }}"
     style="--sp-bg: {{ block.settings.bg_color }}; --sp-text: {{ block.settings.text_color }};">
  <div class="sp-bar-header">{{ block.settings.heading }}</div>
  <div class="sp-bar-items" data-role="items-container">
    {%- if product.metafields.social_proof.notifications -%}
      {%- assign notifications = product.metafields.social_proof.notifications.value -%}
      {%- for notification in notifications limit: block.settings.max_notifications -%}
        <div class="sp-bar-item" data-role="notification">
          {%- if block.settings.show_avatar -%}
          <img class="sp-avatar"
               src="{{ notification.avatar_url | default: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 40 40%22><rect fill=%22%23ddd%22 width=%2240%22 height=%2240%22/></svg>' }}"
               alt=""
               loading="lazy"
               width="32"
               height="32">
          {%- endif -%}
          <span class="sp-name">{{ notification.customer_name }}</span>
          <span class="sp-action">purchased</span>
          <span class="sp-product">{{ notification.product_title }}</span>
          {%- if block.settings.show_time -%}
          <span class="sp-time">{{ notification.time_ago }}</span>
          {%- endif -%}
        </div>
      {%- endfor -%}
    {%- else -%}
      <div class="sp-bar-item sp-placeholder">Loading recent activity...</div>
    {%- endif -%}
  </div>
</div>

<link rel="stylesheet" href="{{ 'social-proof.css' | asset_url }}">
<script src="{{ 'social-proof.js' | asset_url }}" defer></script>

Notice that we render initial content using Liquid and product.metafields. This means the first paint happens server-side with zero JavaScript execution. The JavaScript only enhances the experience by rotating notifications and adding animations.

The JavaScript Enhancement

// extensions/social-proof/assets/social-proof.js
class SocialProofBar extends HTMLElement {
  static observedAttributes = ['data-shop-id', 'data-product-id'];

  constructor() {
    super();
    this.shopId = this.dataset.shopId;
    this.productId = this.dataset.productId;
    this.maxItems = parseInt(this.dataset.maxItems, 10) || 5;
    this.apiBase = this.dataset.apiBase;
    this.currentIndex = 0;
    this.notifications = [];
    this.refreshInterval = null;
  }

  async connectedCallback() {
    // Load notifications from edge-cached API
    this.notifications = await this.fetchNotifications();

    if (this.notifications.length > 1) {
      this.startRotation();
    }
  }

  disconnectedCallback() {
    if (this.refreshInterval) {
      clearInterval(this.refreshInterval);
    }
  }

  async fetchNotifications() {
    const cacheKey = `sp-${this.shopId}-${this.productId}`;
    const cached = this.getFromSessionCache(cacheKey);

    if (cached && Date.now() - cached.timestamp < 120000) {
      return cached.data;
    }

    try {
      const response = await fetch(
        `${this.apiBase}/api/v1/notifications?shop=${this.shopId}&product=${this.productId}&limit=${this.maxItems}`,
        {
          headers: { 'Accept': 'application/json' },
          credentials: 'omit',
        }
      );

      if (!response.ok) throw new Error(`HTTP ${response.status}`);

      const data = await response.json();
      this.setSessionCache(cacheKey, { data: data.notifications, timestamp: Date.now() });
      return data.notifications;
    } catch (error) {
      console.warn('Social proof fetch failed, using server-rendered content:', error);
      return [];
    }
  }

  startRotation() {
    const items = this.querySelectorAll('[data-role="notification"]');
    if (items.length <= 1) return;

    // Hide all except first
    items.forEach((item, i) => {
      if (i > 0) item.style.display = 'none';
    });

    this.refreshInterval = setInterval(() => {
      items[this.currentIndex].style.display = 'none';
      this.currentIndex = (this.currentIndex + 1) % items.length;
      items[this.currentIndex].style.display = 'flex';
      items[this.currentIndex].classList.add('sp-fade-in');
    }, 4000);
  }

  getFromSessionCache(key) {
    try {
      return JSON.parse(sessionStorage.getItem(key));
    } catch {
      return null;
    }
  }

  setSessionCache(key, value) {
    try {
      sessionStorage.setItem(key, JSON.stringify(value));
    } catch {
      // Session storage full or disabled
    }
  }
}

customElements.define('social-proof-bar', SocialProofBar);

The key performance features in this JavaScript:

  • Session cache with 2-minute TTL. The same visitor browsing multiple pages does not re-fetch notifications. Session storage is faster than any HTTP cache because it is synchronous.
  • Graceful degradation. If the API fetch fails, the server-rendered Liquid content is already visible. No blank states, no loading spinners.
  • Omit credentials. No cookies sent with API requests means CDN edge nodes can cache responses across all visitors to the same product page.

The Edge Proxy Layer: Cloudflare Workers

Between the Shopify CDN and our origin server sits a Cloudflare Worker that handles caching and response composition. This is what absorbed the Black Friday traffic.

// Cloudflare Worker: social-proof-edge.js
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const cacheKey = new Request(url.toString(), request);
    const cache = caches.default;

    // Check edge cache first
    const cached = await cache.match(cacheKey);
    if (cached) {
      return cached;
    }

    // Check rate limiting
    const shopId = url.searchParams.get('shop');
    const rateLimitKey = `rate:${shopId}:${Math.floor(Date.now() / 1000)}`;
    const requestCount = await env.KV.get(rateLimitKey);

    if (requestCount && parseInt(requestCount) > 50) {
      return new Response(JSON.stringify({ notifications: [] }), {
        headers: { 'Content-Type': 'application/json' },
      });
    }

    // Increment rate counter
    ctx.waitUntil(
      env.KV.put(rateLimitKey, (parseInt(requestCount || '0') + 1).toString(), {
        expirationTtl: 5,
      })
    );

    // Forward to origin
    const originResponse = await fetch(request);

    if (originResponse.ok) {
      const responseToCache = originResponse.clone();
      // Cache for 60 seconds at the edge
      const headers = new Headers(responseToCache.headers);
      headers.set('Cache-Control', 'public, max-age=60, s-maxage=60');
      const cachedResponse = new Response(responseToCache.body, {
        status: responseToCache.status,
        headers,
      });

      ctx.waitUntil(cache.put(cacheKey, cachedResponse));
    }

    return originResponse;
  },
};

This worker does three things:

  1. Edge caching with 60-second TTL. Popular product pages get cached at the Cloudflare edge. During Black Friday, 73% of API requests were served from edge cache without hitting our origin.
  2. Per-shop rate limiting. At 50 requests per second per shop, we return empty notifications instead of overloading the origin. No merchant's storefront breaks — they just see server-rendered content.
  3. Async cache writes. Using ctx.waitUntil() means the response goes back to the visitor immediately while the cache writes happen in the background.

The Origin API: Laravel Backend

Our Laravel origin server handles two things: serving uncached API requests and processing Shopify webhooks that update the notification data.

API Endpoint

// app/Http/Controllers/Api/NotificationController.php
namespace App\Http\Controllers\Api;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use App\Models\Shop;
use App\Models\Notification;

class NotificationController extends Controller
{
    public function index(Request $request)
    {
        $request->validate([
            'shop' => 'required|string',
            'product' => 'required|string',
            'limit' => 'integer|min:1|max:20',
        ]);

        $shopId = $request->query('shop');
        $productId = $request->query('product');
        $limit = $request->query('limit', 5);

        $cacheKey = "notifications:{$shopId}:{$productId}:{$limit}";

        return Cache::remember($cacheKey, 30, function () use ($shopId, $productId, $limit) {
            $notifications = Notification::where('shop_id', $shopId)
                ->where('product_id', $productId)
                ->where('created_at', '>=', now()->subHours(24))
                ->orderBy('created_at', 'desc')
                ->limit($limit)
                ->get()
                ->map(fn ($n) => [
                    'customer_name' => $n->customer_first_name . ' ' . substr($n->customer_last_name, 0, 1) . '.',
                    'product_title' => $n->product_title,
                    'avatar_url' => $n->avatar_url,
                    'time_ago' => $n->created_at->diffForHumans(),
                ]);

            return response()->json([
                'notifications' => $notifications,
            ]);
        });
    }
}

The Cache::remember with a 30-second TTL means even when traffic spikes and the Cloudflare edge cache expires, our Laravel server only runs the database query once every 30 seconds per product. During Black Friday, this reduced database queries from a theoretical 8.5 million to about 180,000.

Cache Invalidation on Webhook

When a new order comes in, we create a notification and invalidate the relevant cache:

// app/Jobs/ProcessOrderCreated.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Cache;
use App\Models\Notification;

class ProcessOrderCreated implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $backoff = 10;

    public function __construct(
        public string $shopDomain,
        public array $payload,
    ) {}

    public function handle(): void
    {
        $shopId = $this->payload['shop_id'] ?? null;
        $customer = $this->payload['customer'] ?? [];
        $lineItems = $this->payload['line_items'] ?? [];

        foreach ($lineItems as $item) {
            Notification::create([
                'shop_id' => $shopId,
                'product_id' => $item['product_id'],
                'product_title' => $item['title'],
                'customer_first_name' => $customer['first_name'] ?? 'Someone',
                'customer_last_name' => $customer['last_name'] ?? '',
                'avatar_url' => null,
            ]);

            // Invalidate edge cache for this product's notifications
            $this->invalidateCache($shopId, $item['product_id']);
        }
    }

    private function invalidateCache(string $shopId, string $productId): void
    {
        // Clear local Redis cache
        for ($limit = 1; $limit <= 20; $limit++) {
            Cache::forget("notifications:{$shopId}:{$productId}:{$limit}");
        }

        // Purge Cloudflare edge cache via API
        Cache::forget("edge:notifications:{$shopId}:{$productId}");
    }
}

Black Friday Performance Data

Here are the actual metrics from Black Friday 2025:

MetricValue
Total page impressions4.7M
Peak requests/second14,300
Edge cache hit rate73%
Session cache hit rate (client-side)18%
Origin requests/second (peak)3,861
Origin response time (median)42ms
Origin response time (P99)180ms
Database queries/second (peak)620
Failed requests12 (0.0003%)
Merchant storefront impact (page load)+47ms avg

That last number is critical. Our social proof bar added only 47 milliseconds to the average merchant's page load time during the highest traffic event of the year. That is because the Liquid template renders immediately and the JavaScript enhancement is non-blocking.

Caching Strategy Summary

The caching has four layers, each with different TTLs:

Layer 1: Browser Session Storage — 2 minutes
  |  (miss)
  v
Layer 2: Shopify CDN (static assets) — 1 year, versioned URLs
  |  (miss — dynamic API call)
  v
Layer 3: Cloudflare Edge — 60 seconds
  |  (miss)
  v
Layer 4: Laravel Redis Cache — 30 seconds
  |  (miss)
  v
Layer 5: MySQL Database — source of truth

A request can be satisfied at any layer. During normal traffic, about 40% of requests are handled at Layer 1 or 2, 30% at Layer 3, 25% at Layer 4, and only 5% hit the database. During Black Friday, Layer 3 hit rate jumped to 73% because the same product pages were being requested constantly.

Common Mistakes We See

Mistake 1: Fetching data on every page load. Some Theme App Extensions call their API on every single page render with no client-side caching. This works fine until it does not. During traffic spikes, your origin server drowns.

Mistake 2: Blocking rendering on API data. If your Liquid template shows a loading spinner while waiting for JavaScript to fetch data, you have broken the primary benefit of Theme App Extensions. Always render meaningful content in Liquid and enhance with JavaScript.

Mistake 3: No rate limiting at the edge. Without the Cloudflare Worker rate limiter, our origin would have received all 14,300 requests per second. With it, peak origin load was 3,861 — manageable for a 4-instance autoscaled cluster.

Mistake 4: Cache invalidation that is too aggressive. We initially invalidated all cache for a shop whenever any order came in. This destroyed cache hit rates for shops with high order volumes. Switching to per-product invalidation restored the hit rate.

What This Architecture Enables

This multi-layer caching approach does not just handle Black Friday. It changes your cost structure. Our infrastructure bill for December 2025 (including Black Friday) was $142. A naive single-server architecture serving 4.7 million impressions would have required at least a $400/month dedicated cluster. The caching layers turn a potentially expensive infrastructure problem into a solved engineering problem.

Build your Theme App Extensions to render statically first, enhance dynamically second, and cache at every possible layer. Then when Black Friday comes, you can watch the traffic spike on your dashboard and go back to eating turkey.

More from this blog

M

Masud Rana

33 posts

I am highly skilled full-stack software engineer specializing in Laravel, PHP, JS, React, Vue, Inertia.js, and Shopify, with strong experience in Filament Frontend and prompt engineering.