Shopify Store Benchmarks — what we measured across 198 real stores

These are our own readings, not a survey and not someone else's report. Every figure below was measured by reading a store's own public pages, the same way a shopper's browser would. No store is named anywhere on this page.

198
Stores measured
4,603
Product pages read
22,772
Images checked
682 KB
Median homepage weight

How to read this, and what the sample is. These are mostly established direct-to-consumer brands — the kind of store with staff looking after it. That matters: it is a demanding comparison set, not a cross-section of Shopify. If your store measures well against these, it is measuring well against stores with engineering teams. It also means these figures are not a claim about Shopify stores in general, and we do not present them as one. 198 stores is still a small sample. Each row states what it was measured over, because not every reading succeeds on every store — where one failed it is excluded, never counted as a zero.

What most stores get wrong

FindingFigureMeasured over
Homepage images with no alt text34%7,732 of 22,772 images, 194 stores
Stores with at least one image missing alt text94%194 stores
Stores with no H1 heading at all20%198 stores
Stores with exactly one H152%198 stores
Stores where a refund policy could not be found13%198 stores
Stores where a shipping policy could not be found19%198 stores
Products with only one image18%823 of 4,603 products, 161 stores
Stores with at least one single-image product62%161 stores
Products with no description at all11%490 of 4,603 products
Stores with at least one product missing a description40%161 stores
Homepages over 1 MB26%198 stores
Stores with no meta description5%198 stores

The typical store, in numbers

MeasureMedianWorst seen
Homepage load time520 ms5,126 ms
Homepage page weight682 KB8,025 KB
Third-party scripts on the homepage529
Average product description length61 wordsacross 161 stores

Method, in full

Each store was read once from its own public pages — no store login, no app install, nothing that a shopper could not see. The homepage was fetched and its size, time to arrive, third-party script tags, headings, meta description and images recorded. Up to thirty product pages were then read for description length and image count, and the refund and shipping policies were looked for where a store normally links them.

Where a reading failed — a store that would not load, or one whose product pages could not be read — that store is left out of the affected rows rather than recorded as a zero, which is why the samples above differ from row to row. Percentages are rounded to whole numbers. These are single readings taken at one moment, not averages over time, and a store measured today may read differently tomorrow.

Figures on this page are generated from the measurements themselves each time the page is served, so they move as more stores are scored. Nothing here is typed in by hand.

One question at a time

Each of these answers a single question from the same measurements, with the sample stated beside every figure.

Want to know where your own store sits against these?

Score my store — free

It reads your store the same way and tells you which of the problems above you have, ranked by what each one costs you. No sign-up and no store login. Your score is shown against this same set of stores.

A store does not stay fixed. Products change, apps get added, themes get updated, and the things you cleared this month come back next month. Store Watch re-reads your store every month and emails only what changed since last time — newly broken products, ones you fixed, and which way each score moved against these same 198 stores. $49 a month, cancel any time.

Prefer a single look? The Full Store Audit is a one-off $49, offered on your own result page after you have seen the free score.

// Page memory only — nothing is written to the visitor's device. Storing // attribution in sessionStorage is "storage of information in terminal // equipment" under PECR / the ePrivacy Directive and needs prior consent // for a non-essential purpose like ad measurement. Holding it in memory // means attribution covers the page the visitor landed on — which is where // the paid gate form lived — and needs no consent banner. (2026-09-16) var forgeMemoryStore = (function () { var values = {}; return { getItem: function (key) { return Object.prototype.hasOwnProperty.call(values, key) ? values[key] : null; }, setItem: function (key, value) { values[key] = String(value); }, }; })(); function forgeSessionId() { try { var key = 'forge_session_id'; var existing = forgeMemoryStore.getItem(key); if (existing) return existing; var id = (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)); forgeMemoryStore.setItem(key, id); return id; } catch (err) { return null; } } var FORGE_SOURCE_PATTERNS = [ [/(^|\.)google\./, 'google'], [/(^|\.)bing\.com$/, 'bing'], [/(^|\.)yahoo\.com$/, 'yahoo'], [/(^|\.)duckduckgo\.com$/, 'duckduckgo'], [/(^|\.)etsy\.com$/, 'etsy'], [/(^|\.)gumroad\.com$/, 'gumroad'], [/(^|\.)dev\.to$/, 'devto'], [/(^|\.)bsky\.(app|social)$/, 'bluesky'], [/(^|\.)tiktok\.com$/, 'tiktok'], [/(^|\.)pinterest\.[a-z.]+$/, 'pinterest'], [/(^|\.)linkedin\.com$/, 'linkedin'], [/(^|\.)reddit\.com$/, 'reddit'], [/(^|\.)youtube\.com$/, 'youtube'], [/(^|\.)facebook\.com$/, 'facebook'], [/(^|\.)instagram\.com$/, 'instagram'], ]; var FORGE_SEARCH_SOURCES = ['google', 'bing', 'yahoo', 'duckduckgo']; function forgeNormalizeSource(hostname) { if (!hostname) return null; for (var i = 0; i < FORGE_SOURCE_PATTERNS.length; i++) { if (FORGE_SOURCE_PATTERNS[i][0].test(hostname)) return FORGE_SOURCE_PATTERNS[i][1]; } return null; } /** * CARRY THE AD CLICK ACROSS THE HOP THAT LEADS TO THE MONEY. * * Google gives us ?gclid=... on the landing URL and nothing else. Because * nothing is stored on the visitor's device, that id exists only for as * long as it is in the address bar — so any internal link that does not * carry it forward ends the trail, and a sale that follows can never be * reported back to Ads as that click's conversion. * * Deliberately narrow: only links to a product page or one of its samples, * which is the path to a payment. The id is not sprinkled across guides and * policy pages, where it would do nothing except end up in a URL somebody * pastes to a friend. */ function forgeCarryAdClick() { try { var gclid = new URLSearchParams(window.location.search).get('gclid'); if (!gclid) return; var links = document.querySelectorAll('a[href]'); for (var i = 0; i < links.length; i++) { var href = links[i].getAttribute('href'); if (!href) continue; var url; try { url = new URL(href, window.location.href); } catch (err) { continue; } if (url.origin !== window.location.origin) continue; // Plain string tests, not a regex: this whole function is emitted // through a template literal, where a backslash is eaten before it ever // reaches the browser, so an escaped regex here would ship broken. var path = url.pathname; var isMoneyPath = path.indexOf('/products/') === 0 || path.indexOf('-sample') !== -1; if (!isMoneyPath) continue; if (url.searchParams.get('gclid')) continue; url.searchParams.set('gclid', gclid); links[i].setAttribute('href', url.pathname + url.search + url.hash); } } catch (err) {} } function forgeCaptureAttribution() { var sessionId = forgeSessionId(); try { var FIRST_KEY = 'forge_first_touch'; var LAST_KEY = 'forge_last_touch'; var params = new URLSearchParams(window.location.search); var utmSource = params.get('utm_source'); var utmMedium = params.get('utm_medium'); var utmCampaign = params.get('utm_campaign'); var utmContent = params.get('utm_content'); var utmTerm = params.get('utm_term'); // Google's ad-click identifier. Identifies the click, not the person, // and is only ever sent onward as Stripe Checkout metadata so a // settled payment can be reported back to Ads as a conversion. var gclid = params.get('gclid'); var hasUtm = !!(utmSource || utmMedium || utmCampaign || utmContent || utmTerm); // Privacy: the referring URL's query string and fragment are dropped // before anything is stored or sent. A raw document.referrer can carry // the visitor's own search terms (google.com/search?q=...) or a // one-time token from a webmail/app link — neither is needed to tell // direct from organic from referral, and neither belongs in EventLog. // Origin + path is the most that is ever kept. var referrerRaw = document.referrer || ''; var referrer = ''; var referrerHostname = null; var isInternalReferrer = false; if (referrerRaw) { try { var refUrl = new URL(referrerRaw); referrerHostname = refUrl.hostname; // Every name this site answers to, not just the one in the address // bar. An exact match called instilus.com an external referrer of // www.instilus.com, and recorded instilus.com as acquisition // evidence for pages still served from the Railway host. // Customer-facing hosts only. The legacy deployment hostname is // deliberately NOT listed here: it would ship that hostname into every // page's source, and historical referrals carrying it are already // rejected server-side by isSelfReferral(). var own = ['instilus.com','www.instilus.com']; var refHost = String(refUrl.hostname || '').toLowerCase(); var hereHost = String(window.location.hostname || '').toLowerCase(); isInternalReferrer = (refHost === hereHost) || own.indexOf(refHost) !== -1 || own.indexOf(refHost.replace(/^www./, '')) !== -1; referrer = refUrl.origin + refUrl.pathname; } catch (err) {} } var hasExternalReferrer = !!(referrerHostname && !isInternalReferrer); var existingFirstRaw = null; try { existingFirstRaw = forgeMemoryStore.getItem(FIRST_KEY); } catch (err) {} var isNewTouch = hasUtm || hasExternalReferrer || !existingFirstRaw; // This page's own present-only utm_* params, returned alongside the // touches so every beacon inherits the top-level utm fields from the // one shared module instead of hand-rolling its own partial copy // (which is how utm_term came to be captured into first-touch but // never actually sent by any beacon). var utm = {}; if (utmSource) utm.source = utmSource; if (utmMedium) utm.medium = utmMedium; if (utmCampaign) utm.campaign = utmCampaign; if (utmContent) utm.content = utmContent; if (utmTerm) utm.term = utmTerm; if (!isNewTouch) { var existing = existingFirstRaw ? JSON.parse(existingFirstRaw) : null; return { firstTouch: existing, lastTouch: existing, sessionId: sessionId, utm: utm, gclid: gclid }; } var normalizedSource = forgeNormalizeSource(referrerHostname); var resolvedSource = utmSource || (hasExternalReferrer ? (normalizedSource || referrerHostname) : 'direct/unknown'); var resolvedMedium = utmMedium || (hasExternalReferrer ? (FORGE_SEARCH_SOURCES.indexOf(normalizedSource) !== -1 ? 'organic' : 'referral') : null); var touch = { source: resolvedSource, medium: resolvedMedium, campaign: utmCampaign || null, content: utmContent || null, term: utmTerm || null, referrer: referrer || null, referrerHostname: referrerHostname, // On the touch for the same reason the campaign is: it identifies the // click, not the person, and only ever leaves the browser as Stripe // Checkout metadata. // // Note what this does NOT do. forgeMemoryStore is a plain in-memory // object, on purpose — nothing is written to the visitor's device, so // no consent banner is needed under PECR reg 6. That means this touch // is rebuilt from the current URL on every page load and cannot carry // anything across a navigation. The URL is the only thing that // survives a hop, which is what forgeCarryAdClick below exists for. gclid: gclid || null, landingPage: window.location.pathname, landingTimestamp: new Date().toISOString(), }; try { if (!existingFirstRaw) forgeMemoryStore.setItem(FIRST_KEY, JSON.stringify(touch)); forgeMemoryStore.setItem(LAST_KEY, JSON.stringify(touch)); } catch (err) {} var first = existingFirstRaw ? JSON.parse(existingFirstRaw) : touch; return { firstTouch: first, lastTouch: touch, sessionId: sessionId, utm: utm, gclid: gclid }; } catch (err) { return { firstTouch: null, lastTouch: null, sessionId: sessionId, utm: {}, gclid: null }; } } /** Merges first/last-touch fields onto an existing beacon body object, present-fields-only (never fabricated nulls sent over the wire). */ function forgeAttachAttribution(body, attribution) { if (attribution.sessionId) body.sessionId = attribution.sessionId; // Both readings of the same page load — the store holds no more than the // URL already gave it (see the note on forgeMemoryStore). Kept because it // costs nothing and is correct either way. var attrGclid = attribution.gclid || (attribution.firstTouch && attribution.firstTouch.gclid); if (attrGclid && !body.gclid) body.gclid = attrGclid; // Top-level utm_* from this page's own URL. Only fills a field the // caller has not already set, so a beacon that deliberately supplies // its own default (the embedded calculator's utm_source/utm_medium) // keeps it. var utm = attribution.utm; if (utm) { if (utm.source && !body.utmSource) body.utmSource = utm.source; if (utm.medium && !body.utmMedium) body.utmMedium = utm.medium; if (utm.campaign && !body.utmCampaign) body.utmCampaign = utm.campaign; if (utm.content && !body.utmContent) body.utmContent = utm.content; if (utm.term && !body.utmTerm) body.utmTerm = utm.term; } var ft = attribution.firstTouch; if (ft) { if (ft.source) body.firstTouchSource = ft.source; if (ft.medium) body.firstTouchMedium = ft.medium; if (ft.campaign) body.firstTouchCampaign = ft.campaign; if (ft.content) body.firstTouchContent = ft.content; if (ft.term) body.firstTouchTerm = ft.term; if (ft.referrer) body.firstTouchReferrer = ft.referrer; if (ft.referrerHostname) body.firstTouchReferrerHostname = ft.referrerHostname; if (ft.landingPage) body.landingPage = ft.landingPage; } var lt = attribution.lastTouch; if (lt) { if (lt.source) body.lastTouchSource = lt.source; if (lt.medium) body.lastTouchMedium = lt.medium; if (lt.referrer) body.lastTouchReferrer = lt.referrer; if (lt.referrerHostname) body.lastTouchReferrerHostname = lt.referrerHostname; } return body; } // Runs on every page this module is inlined into, including the stored // product landing pages that refreshAttributionScript rewrites at serve // time. Reads only the URL and rewrites only same-origin hrefs, so there is // nothing to undo and nothing to store. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', forgeCarryAdClick); } else { forgeCarryAdClick(); }