Most Shopify Function tutorials show you the happy path. Here's what actually broke during 3 months of building a production discount engine.
The Setup
I spent 3 months building DealCraft — a discount app that supports 12+ discount types (percentage, BOGO, volume, bundle, spend-tier, mix-and-match, etc.). The discount engine is a Rust Function compiled to WASM, running at Shopify's checkout. I chose Rust because the Function runtime demands it, and because I wanted the type safety for complex discount stacking logic.
The tech was straightforward. The edge cases weren't.
1. WASM Has No Clock
Our app supports time-windowed discounts — "10% off, valid from Oct 1 to Oct 7." Simple, right?
Except std::time::SystemTime::now() panics in Shopify's WASM runtime. There's no concept of "now" inside a Function.
The fix: Evaluate time conditions at sync time, not at checkout time. When the merchant saves a rule, the backend checks if the current time falls within the window. If not, the rule isn't written to the metafield at all. If it is, the time condition is "constant-folded" to true in the config JSON.
Merchant saves rule (Oct 1-7)
→ Backend checks: is now() within Oct 1-7?
→ No → Don't write to metafield
→ Yes → Write rule with timeCondition: true (already satisfied)
The downside: if a rule's window starts while the Function is cached, it won't activate until the next sync. We handle this with two scheduled jobs — an hourly cron that auto-activates rules whose scheduledStart has arrived, and a 15-minute full sync that re-evaluates all shops as a safety net.
2. GraphQL Query Cost Almost Killed Us
Shopify Functions have a query complexity limit of 30. Our initial approach stored each discount rule as a separate metafield on the Shop object:
query {
shop {
rule1: metafield(namespace: "$app", key: "rule_1") { value }
rule2: metafield(namespace: "$app", key: "rule_2") { value }
# ... × 12 rules
}
}
12 metafield lookups × 3 complexity each = 36 units. Rejected at deploy time.
The fix: Store all rules in a single Metaobject with a JSON field. One metaobject lookup costs 4 units (1 root + 3 for field(key)), well within budget. The metaobject also supports 128KB values, while metafields exceeding 10,000 bytes are not returned by the Function input query (the value is stored, but the Function receives null).
query {
shop {
rules: metaobject(handle: "dealcraft_rules") {
field(key: "config") { value }
}
}
}
Lesson: Design your data model around query cost, not just data shape.
3. Discount Code + Automatic Discount Collision
Shopify has two discount mechanisms:
- Discount codes — customer enters a code at checkout
- Automatic discounts — applied by Functions without customer action
Our app creates automatic discounts via the Function. But merchants also create discount codes natively in Shopify. When a customer enters a code AND our Function has an automatic discount, who wins?
Shopify's default behavior: both apply. That's a merchant nightmare — double-dipping discounts.
The fix — "Strategy C": The Function reads the entered_discount_codes from the cart input and checks them against its own rule configurations. Each rule in the metafield carries a codes array — the discount codes associated with that rule. When a customer enters a code:
Code matches a rule the Function manages → The Function checks if the cart satisfies that rule's conditions AND not all lines are excluded. If both pass, the Function yields (returns empty) and lets the native discount code handle it. If either fails (cart doesn't qualify OR all lines are excluded), the Function rejects the code via
EnteredDiscountCodesRejectwith a message like "This discount code is not valid for the items in your cart."Code doesn't match any rule → The Function does NOT reject it. Instead, it simply suppresses its own automatic discounts and lets Shopify handle the entered code natively.
// Step 1: Validate entered codes — reject if cart doesn't qualify
for entered in &ctx.entered_codes {
if !entered.rejectable { continue; }
for rule in &rules {
if rule.codes.is_empty() { continue; }
if rule.codes.iter().any(|c| c.eq_ignore_ascii_case(&entered.code)) {
let conditions_met = check_conditions(rule.conditions, ctx);
let all_excluded = check_all_lines_excluded(rule, ctx);
if !conditions_met || all_excluded {
return reject_code(entered.code, "This discount code is not valid for the items in your cart");
}
break; // One code belongs to one rule
}
}
}
// Step 2: Match rules and calculate discounts...
// Step 3: If any discount code is entered, suppress automatic discounts
if ctx.has_discount_code {
return Ok(empty_result());
}
This gives merchants one mental model: "DealCraft handles all discounts." Unknown codes pass through; known codes are validated against our rules.
4. A/B Testing Required Deterministic Hashing Across Two WASM Targets
We built A/B testing for discount rules — show 50% of customers a 10% discount, the other 50% a 15% discount. The split must be deterministic: same customer always sees the same variant.
The tricky part: our unified Function has two WASM export targets — cart_lines (product discounts) and delivery_options (free shipping). Both targets evaluate the same A/B test independently. If they hash differently, a customer could see 10% on products but 15% on shipping — a merchant nightmare.
Both targets share the same domain module, so the hash function itself is identical. But the inputs must also be identical. We use format!("{}{}", identity, anchor) where:
- identity: customer ID for logged-in users, cart token for guests
-
anchor:
min(rule.id, rule.ab_test_variant_id)— lexicographic minimum, ensuring both the control and variant rule compute the same hash seed
fn simple_hash(s: &str) -> i64 {
let mut hash: i32 = 0;
for cu in s.encode_utf16() {
hash = hash
.wrapping_shl(5) // hash * 32 (mod 2^32)
.wrapping_sub(hash) // - hash = hash * 31 (mod 2^32)
.wrapping_add(cu as i32);
}
(hash as i64).abs()
}
// Bucket assignment
let hash = simple_hash(&format!("{}{}", identity, anchor));
let bucket = (hash % 100) as u32;
let in_variant = bucket < rule.ab_test_split;
We use UTF-16 code units (encode_utf16() returns u16 values) rather than Rust's native UTF-8 bytes because the original prototype was in JavaScript (charCodeAt() returns UTF-16 code units). The compatibility is at the code unit level — both produce the same u16 values for the same characters. The i32 accumulator with wrapping_shl(5).wrapping_sub(hash) is the classic Java string hash (hash * 31 + codeUnit), chosen specifically because it's what the JS prototype used. The wrapping_* operations ensure overflow behavior matches JavaScript's silent wraparound (though JS uses floating-point, the integer results are equivalent for typical string lengths).
The key lesson: when you have multiple evaluation points for the same decision, "same function" isn't enough. The inputs must also be guaranteed identical. The anchor = min(id, variant_id) trick ensures that regardless of which rule (control or variant) is evaluating, the hash seed is the same.
5. Free Shipping Has Different Exclusion Semantics
Our unified Function handles both cart discounts and free shipping in a single WASM binary (two export targets, shared domain logic). But exclusion rules behave differently:
- Cart discount: If product A is excluded, only product A is removed from the discount. Other products still get discounted.
- Free shipping: If ANY product in the cart is excluded, the entire free shipping discount is voided.
Why? Because delivery groups can't be split per-line. You either get free shipping on the whole order or you don't.
Concrete example: Cart has products A, B, C. Product A is excluded from the discount.
- Cart discount: A is removed, B and C still get 10% off
- Free shipping: Entire discount voided because A is in the cart
This meant the same exclusion config produced different outcomes depending on the target. We had to add a strict_exclusion flag in the domain module:
fn check_exclusions(lines: &[Line], excluded_ids: &[String], strict: bool) -> bool {
if strict {
// Free shipping: any excluded line → block entire discount
!lines.iter().any(|l| excluded_ids.contains(&l.product_id))
} else {
// Cart discount: filter out excluded lines, discount the rest
true // always pass; filtering happens downstream
}
}
Small difference, massive debugging session.
What I'd Do Differently
Looking back:
- Start with query cost analysis — before writing a single line of Rust, calculate your GraphQL query complexity. We lost a week redesigning our data model after the first deploy failed.
- Test with edge cases from day one — our best bug finds came from real carts with discount codes + automatic discounts + excluded products all at once. Unit tests missed these interactions.
- Unify early — the separate free-shipping Function was a mistake. Maintaining two WASM binaries with shared logic was harder than one binary with two targets.
If you're building Shopify Functions, what edge cases have you hit? I'm especially curious about how others handle the query cost limit — did you find creative solutions beyond metaobjects?
Top comments (0)