· Jonathan Sorenson · Shopify Functions  · 8 min read

How I Shrunk My Shopify Functions by 60% (And Saved My Sanity)

A developer's guide to fighting the 256KB bytecode limit

A developer's guide to fighting the 256KB bytecode limit

256KB Limit

256KB of compiled WebAssembly bytecode. That’s all you get. No exceptions, no workarounds, no “enterprise tier” with higher limits.

I’ve spent more than 70 hours during last 3 months optimizing Shopify Functions.

This is the playbook that will help you do it much faster.

Understanding the Real Problem

First, let’s be clear about what we’re fighting:

The 256KB limit isn’t about your source code. I’ve seen 10,000-line functions compile to 180KB and 500-line functions blow past 300KB. It’s about the compiled WebAssembly bytecode – the final output after Rust compilation, optimization, and packaging.

Think of it like packing for a flight with strict baggage limits. It doesn’t matter if you have designer clothes or basics – what matters is how efficiently you pack and how much each item weighs after compression.

Here’s what typically eats up space in a Shopify Function:

  • Error strings and debug messages: 10-20% of bytecode
  • Generic function instantiations: 15-25% of bytecode
  • Unnecessary dependencies: 10-40% of bytecode
  • Derived trait implementations: 5-15% of bytecode
  • Oversized data types: 5-10% of bytecode

The good news? Most of this is fixable.

The 10 Optimizations That Actually Matter

1. Audit Your Dependencies Like Your Business Depends on It

Every dependency brings baggage. Even with Rust’s excellent dead code elimination, core functionality gets pulled in.

// The expensive way
use chrono::DateTime;  // Brings in timezone data, parsing logic, formatting
use regex::Regex;      // Entire regex engine, even for simple patterns
use serde_json::Value; // Dynamic allocation, formatting code

// The efficient way
use time::Date;        // Lighter alternative to chrono
use serde::Deserialize; // Only what you need

// Or hand-roll when simple enough
fn parse_date(s: &str) -> Result<(u32, u32, u32), Error> {
    let parts: Vec<&str> = s.split('-').collect();
    if parts.len() != 3 { return Err(Error::InvalidDate); }

    Ok((
        parts[0].parse()?,
        parts[1].parse()?,
        parts[2].parse()?
    ))
}

2. Break Up with Closures (It’s Not You, It’s Them)

Rust’s iterator chains are beautiful. They’re also bytecode hogs.

// The elegant but expensive way
let discounts: Vec<_> = items
    .iter()
    .filter(|item| item.is_eligible())
    .map(|item| calculate_base_price(item))
    .filter(|price| *price > threshold)
    .map(|price| apply_discount(price, rate))
    .filter_map(|result| result.ok())
    .collect();

// The ugly but efficient way
let mut discounts = Vec::new();
for item in items {
    if !item.is_eligible() { continue; }

    let price = calculate_base_price(item);
    if price <= threshold { continue; }

    if let Ok(discounted) = apply_discount(price, rate) {
        discounts.push(discounted);
    }
}

Each closure creates unique code. The optimized version? One function, 40% less bytecode.

When to break this rule: If the performance gain from iterator optimizations outweighs the size cost. Profile both size AND speed.

3. Generic Functions Are a Luxury You Can’t Afford

Generics in Rust are zero-cost at runtime but expensive in bytecode. Each type parameter creates a new copy of the function.

// Creates multiple copies in bytecode
fn process_discount<T: Discount>(item: T) -> Money {
    item.calculate()
}

// Usage generates multiple instantiations
process_discount(ProductDiscount { ... });  // Copy 1
process_discount(CategoryDiscount { ... }); // Copy 2
process_discount(BundleDiscount { ... });   // Copy 3

// Single implementation
fn process_discount(item: &dyn Discount) -> Money {
    item.calculate()
}

// Or use concrete types
enum DiscountType {
    Product(ProductDiscount),
    Category(CategoryDiscount),
    Bundle(BundleDiscount),
}

fn process_discount(item: DiscountType) -> Money {
    match item {
        DiscountType::Product(d) => d.calculate(),
        DiscountType::Category(d) => d.calculate(),
        DiscountType::Bundle(d) => d.calculate(),
    }
}

The tradeoff: You lose some type safety and might see minor performance impacts. But when you’re 20KB over the limit, it’s worth it.

4. Derive Responsibly

Those convenient derive macros? They’re code generators in disguise.

// The kitchen sink approach - adds ~2KB per struct
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
struct Product {
    id: String,
    title: String,
    price: Money,
    vendor: String,
    tags: Vec<String>,
}

// The minimalist approach - adds ~200 bytes
#[derive(Deserialize)]
struct Product {
    id: String,
    #[serde(default)]
    title: String,
    price: Money,
    #[serde(default)]
    vendor: String,
    #[serde(default)]
    tags: Vec<String>,
}

// Implement only what you actually use
impl Product {
    fn new(id: String, price: Money) -> Self {
        Self {
            id,
            title: String::new(),
            price,
            vendor: String::new(),
            tags: Vec::new(),
        }
    }
}

Run your tests. If they pass without a derive, you didn’t need it.

5. Right-Size Your Data Types

This one’s subtle but adds up fast.

// The wasteful way - 28 bytes per instance
struct Discount {
    percentage: f64,     // 8 bytes (for 0-100?)
    max_uses: i64,       // 8 bytes (billions of uses?)
    priority: i32,       // 4 bytes (thousands of priorities?)
    min_quantity: i32,   // 4 bytes
    max_quantity: i32,   // 4 bytes
}

// The efficient way - 6 bytes per instance
struct Discount {
    percentage: u8,      // 1 byte (0-100)
    max_uses: u16,       // 2 bytes (up to 65,535)
    priority: u8,        // 1 byte (up to 255)
    min_quantity: u8,    // 1 byte (up to 255)
    max_quantity: u8,    // 1 byte (up to 255)
}

When you have arrays or vectors of these structs, the savings multiply. 100 discounts? You just saved 2.2KB.

Tip: Use #[repr(packed)] carefully – it can save space but may hurt performance on some architectures.

6. Debug Code Is Production’s Enemy

Debug strings are silent killers. They sit there, looking innocent, eating your bytecode.

// The hidden cost
eprintln!("Processing discount for product: {:?}", product);
eprintln!("Calculated base price: {}", base_price);
eprintln!("Applying tier: {:?}", tier);
eprintln!("Final price: {}", final_price);

// The production-ready way
#[cfg(debug_assertions)]
macro_rules! debug_log {
    ($($arg:tt)*) => {
        eprintln!($($arg)*);
    };
}

#[cfg(not(debug_assertions))]
macro_rules! debug_log {
    ($($arg:tt)*) => {};
}

debug_log!("Processing discount for product: {:?}", product);
debug_log!("Calculated base price: {}", base_price);

7. Feature Flags Are Your Friend

Rust’s feature system lets you build different versions for different functions.

# Cargo.toml
[features]
default = ["basic"]
basic = []
bundles = ["advanced-math"]
tiered = ["analytics"]
enterprise = ["bundles", "tiered", "customer-history"]

[dependencies]
advanced-math = { version = "1.0", optional = true }
analytics = { version = "2.0", optional = true }
// lib.rs
#[cfg(feature = "bundles")]
mod bundle_calculator;

#[cfg(feature = "tiered")]
mod tier_engine;

#[cfg(feature = "analytics")]
fn track_discount_usage(discount: &Discount) {
    // Full implementation
}

#[cfg(not(feature = "analytics"))]
fn track_discount_usage(_: &Discount) {
    // No-op for builds without analytics
}

Build different functions for different merchants:

  • Basic store? cargo build --features basic → 80KB
  • Complex bundles? cargo build --features bundles → 140KB
  • Enterprise? cargo build --features enterprise → 200KB

8. Hand-Roll format! When It Matters

The format! macro is convenient but brings significant overhead. For simple formatting tasks, a custom implementation can save substantial space.

// The convenient but expensive way
fn format_price(value: f32) -> String {
    format!("{:.2}", value)
}

// Hand-rolled version - saved me 10KB
fn format_as_integer(value: f32) -> String {
    let truncated = value as i16;
    let mut buffer = [0u8; 10];
    let mut pos = 9;

    let mut num = truncated.abs();
    if num == 0 {
        buffer[pos] = b'0';
        pos -= 1;
    } else {
        while num > 0 {
            buffer[pos] = (num % 10) as u8 + b'0';
            num /= 10;
            pos -= 1;
        }
    }

    if truncated < 0 {
        buffer[pos] = b'-';
        pos -= 1;
    }

    let slice = &buffer[pos + 1..10];
    let mut result = String::with_capacity(slice.len());
    result.push_str(unsafe { std::str::from_utf8_unchecked(slice) });
    result
}

The savings: In one of my functions, replacing format! calls with hand-rolled alternatives saved 10KB of bytecode - nearly 4% of the total limit.

9. Strip Debug Paths with —remap-path-prefix

Debug information includes full file paths, which can add surprising amounts to your bytecode. The --remap-path-prefix flag helps strip these down.

# .cargo/config.toml
[build]
rustflags = [
    "--remap-path-prefix=/Users/yourname/very/long/project/path/shopify-function=.",
    "--remap-path-prefix=/home/runner/.cargo/registry/src/index.crates.io-=",
    "-C", "strip=symbols",
]
# Or via environment variable
export RUSTFLAGS="--remap-path-prefix=$HOME/.cargo/registry/src/index.crates.io-= --remap-path-prefix=$(pwd)=."
cargo build --release

What this does:

  • Replaces long absolute paths with . in debug info
  • Strips cargo registry paths entirely
  • Removes debugging symbols completely

Typical savings: 2-8KB depending on project structure and dependency count. Every character in those paths gets embedded in the binary.

Pro tip: Combine with panic = "abort" in your Cargo.toml’s release profile to eliminate panic unwinding code entirely:

[profile.release]
panic = "abort"
strip = true
lto = true
codegen-units = 1

10. Error Codes Over Error Strings

This one hurts. We all love descriptive errors. But they’re expensive.

// The developer-friendly way - adds 5-10KB
fn validate_discount(discount: &Discount) -> Result<(), String> {
    if discount.percentage > 100 {
        return Err("Invalid discount: percentage must be between 0 and 100".into());
    }
    if discount.min_quantity > discount.max_quantity {
        return Err("Invalid configuration: minimum quantity exceeds maximum".into());
    }
    if discount.expires_at < now() {
        return Err("Discount has expired".into());
    }
    Ok(())
}

// The production way - adds 100 bytes
#[repr(u8)]
enum ErrorCode {
    InvalidPercentage = 1,
    InvalidQuantityRange = 2,
    DiscountExpired = 3,
}

fn validate_discount(discount: &Discount) -> Result<(), ErrorCode> {
    if discount.percentage > 100 {
        return Err(ErrorCode::InvalidPercentage);
    }
    if discount.min_quantity > discount.max_quantity {
        return Err(ErrorCode::InvalidQuantityRange);
    }
    if discount.expires_at < now() {
        return Err(ErrorCode::DiscountExpired);
    }
    Ok(())
}

// Map to strings only in development or external APIs
#[cfg(debug_assertions)]
impl ErrorCode {
    fn to_string(&self) -> &'static str {
        match self {
            Self::InvalidPercentage => "Invalid discount percentage",
            Self::InvalidQuantityRange => "Invalid quantity range",
            Self::DiscountExpired => "Discount expired",
        }
    }
}

Document your error codes well. Your future self will thank you.

What’s Next?

These optimizations work, but they’re painful. You shouldn’t need a PhD in WebAssembly optimization to build Shopify Functions.

That’s why I’m building a no-code Shopify Function builder. It implements all these optimizations automatically, letting merchants focus on business logic instead of bytecode limits.

The goal? Complex functions that would normally require 300KB of hand-written code, compiled down to under 100KB automatically.

Your Turn

Start with the easy wins:

  1. Remove debug strings (saves 10-30%)
  2. Audit dependencies (saves 10-40%)
  3. Replace error strings with codes (saves 5-15%)

Then tackle the structural changes:

  1. Hand-roll critical formatting functions
  2. Strip debug paths with —remap-path-prefix
  3. Reduce generic usage
  4. Optimize data types
  5. Simplify control flow

Remember: Every byte counts, but not every optimization is worth the complexity. Profile first, optimize second, and always keep your code maintainable.

Have you hit Shopify’s function limits? What optimizations worked for you? Drop a comment or reach out – I’m always interested in war stories from the trenches.


Want to discuss a migration from Scripts or need help with optimizing your Shopify Functions? Book a consultation.

Back to Blog

Related Posts

View All Posts »