Skip to main content

Optimize instruction counts

Shopify Functions run inside critical buyer flows, such as cart and checkout. Every WebAssembly instruction that your function executes adds latency for the buyer, and a single checkout request can run many functions. The fewer instructions your function uses, the faster checkout is for your merchants' customers, and the more headroom your function has for large carts and complex configurations.

This guide explains how instruction counts work, where to find them, and how to reduce them. The biggest improvements usually come from the following steps, in order:

  1. Migrate from JavaScript to Rust.
  2. Request only the input that your function needs.
  3. Profile your function and optimize the hot paths, optionally with an AI agent.
  4. Track instruction counts in your tests so that they don't regress.

Anchor to How instruction counts workHow instruction counts work

Shopify runs your function's WebAssembly module and counts every instruction that it executes. For carts with up to 200 line items, a function can execute up to 11 million instructions. For larger carts, the limit scales proportionally with the number of cart lines. If your function exceeds the limit, then Shopify stops it and reports an InstructionCountLimitExceededError. For the full list of limits, refer to function resource limits.

The instruction count for a run depends on the following factors:

  • Language and runtime: Languages that compile directly to WebAssembly, such as Rust, execute your logic as native WebAssembly instructions. JavaScript functions run on a JavaScript engine that interprets your code, so each line of JavaScript costs many more WebAssembly instructions.
  • Input size: Your function spends instructions reading its input. Larger input queries, more cart lines, and larger metafield values all increase the count.
  • Logic: Loops over cart lines, string manipulation, memory allocation, and logging all execute instructions.

Instruction counts are deterministic. The same WebAssembly module and the same input produce the same count, so you can measure the effect of a change locally.

Note

Staying under the limit isn't the goal. A function that runs close to the limit on a typical cart can fail on a larger one, and every instruction adds checkout latency. Aim to keep your instruction counts as low as you reasonably can.


Anchor to Measure instruction countsMeasure instruction counts

You can find the instruction counts of your function's production runs in the Dev Dashboard, and measure them locally with Shopify CLI as you make changes.

Anchor to In the Dev DashboardIn the Dev Dashboard

The Dev Dashboard shows the instruction count for each function run, along with the limit that applied to that run. Look for runs with high instruction counts, and use their input to reproduce them locally. Run input is available when your app has the access scopes required by the function's input query.

Anchor to Locally with Shopify CLILocally with Shopify CLI

Use app function run to execute your function locally with a JSON input. The command runs the WebAssembly module that's already built, so build your function first. Shopify CLI prints the function output, followed by benchmark results that include the instruction count and the limit for that input:

Terminal

shopify app function build
shopify app function run --input input.json

The end of the output shows the limits that apply to that input, and the measurements from the run. For example:

Example benchmark results

Resource Limits

Input Size: 125.00KB
Output Size: 19.53KB
Instructions: 11M


Benchmark Results

Name: my-function
Linear Memory Usage: 1088KB
Instructions: 2.364917M
Input Size: 3.42KB
Output Size: 412B
Module Size: 94KB

The Instructions value under Benchmark Results is the number of instructions that your function executed. The Instructions value under Resource Limits is the limit for that input, which scales with the number of cart lines.

If your function has more than one target, then Shopify CLI prompts you to choose the export to run. To skip the prompt, pass --export with the export value of the target from your shopify.extension.toml file.

To measure with real input instead of input that you write yourself, replay a run from your dev store. While app dev is running, Shopify runs your function whenever you trigger it on your dev store, for example by adding products to a cart. Shopify CLI saves the input and output of each of these runs to your app's .shopify/logs directory. Use app function replay to re-run one of those executions locally with the same input. For more information, refer to Test and debug Shopify Functions.

When you measure, use inputs that reflect your heaviest real workloads, such as carts with many line items or merchants with large configurations. A function that performs well on a one-line cart can behave very differently on a 200-line cart.

Info

Pass --json to app function run to get the output and measurements as JSON. The instructions field contains the instruction count, which you can use in your own scripts and reports.


Anchor to Migrate from JavaScript to RustMigrate from JavaScript to Rust

JavaScript functions are compiled with Javy, which produces a WebAssembly module that's linked to a JavaScript engine at runtime. The engine interprets your code as it runs, so a JavaScript function executes significantly more instructions than the equivalent Rust function. Migrating to Rust is usually the step that will have the largest impact on reducing your function's instruction count.

You don't need to create a new extension to migrate. Add your Rust code alongside your JavaScript code in the same function extension, and use shopify.extension.toml to choose which language to build. Because the extension doesn't change, merchants don't need to do anything, and you can switch back to JavaScript if you find an issue. For step-by-step instructions, refer to Migrating from JavaScript.

Before you migrate, collect a set of test fixtures from your JavaScript function's real runs. You don't need to add any instrumentation to collect them. While app dev is running, Shopify CLI saves every run on your dev store as a log file in .shopify/logs, and the Dev Dashboard shows the input and output of production runs when your app has the required access scopes. To turn these runs into fixtures, refer to Add additional test fixtures. Use the fixtures to confirm that the Rust function returns the same output for the same input, and to compare instruction counts before and after. For more information, refer to Track instruction counts in your tests. AI coding agents are effective at porting function logic between languages when they have fixtures to check their work against.

Anchor to If you stay on JavaScriptIf you stay on JavaScript

If you can't migrate yet, then you can still reduce your JavaScript function's instruction count:

  • Use the latest version of Shopify CLI and version 2.0.0 or higher of the @shopify/shopify_function package.
  • Keep your bundle small. Remove unused npm dependencies, and prefer small, focused libraries. Every bundled dependency adds code that the JavaScript engine needs to load and run.
  • Apply the input and logic techniques in this guide, which apply to every language.

Anchor to Reduce your function's inputReduce your function's input

The more data your function receives, the more instructions it takes to read and process it. Keep your input as small as possible:

  • Query only the fields that you use. Review your input query, such as run.graphql, and remove any fields that your function doesn't read.
  • Let Shopify evaluate conditions for you. Fields such as hasAnyTag and inAnyCollection return a single boolean instead of lists that your function needs to search. Use input query variables to make their arguments configurable per merchant.
  • Read JSON metafields with jsonValue. Query jsonValue instead of parsing a JSON string from value in your function code.
  • Keep configuration small and precomputed. Do as much work as possible when a merchant saves their settings, and store the result in a metafield in the shape that your function needs. For example, store a lookup of product IDs instead of rules that your function needs to evaluate on every run.

Anchor to Write efficient function logicWrite efficient function logic

The following practices reduce instruction counts in any language:

  • Return early. If your function has nothing to do for a cart, such as when a discount doesn't apply, then return an empty result before doing any other work.
  • Avoid nested loops over cart lines. Comparing every line with every other line grows quickly with cart size. Build a map or set in one pass, and then look values up.
  • Avoid repeated work. Compute values once and reuse them, instead of recalculating them for every cart line.
  • Minimize string work. String formatting, concatenation, and case conversion all execute instructions. Compare IDs and values directly where you can.
  • Remove debug logging. Logging executes instructions even when logs are truncated. Keep only the logs that you need to diagnose production runs. Because function runs are deterministic, you can add detailed logging to a local build instead, and reproduce a production run with its input by using app function replay or app function run.

For Rust functions, also consider the following practices:

  • Use the latest version of the shopify_function crate. Newer versions include performance improvements.
  • Borrow data instead of cloning it, and avoid allocating new String and Vec values inside loops.
  • Use to_ascii_lowercase and to_ascii_uppercase instead of Unicode-aware case conversion when your data allows it.
  • Experiment with the opt-level setting in the [profile.release] section of your Cargo.toml. Function templates use opt-level = "z" to keep the binary small, but other levels can produce fewer instructions. Measure both the instruction count and the binary size, because your compiled binary must stay under 256 kB.

Anchor to Profile your functionProfile your function

A profile shows which functions in your code execute the most instructions, so you can focus on the parts that matter. Shopify CLI generates profiles that you can open in Speedscope.

Note

Profiles are most useful for Rust and other languages that compile directly to WebAssembly. JavaScript functions built with Javy don't include function names in their WebAssembly module, so every frame in the profile appears as <unknown>.

Function names help you read a profile. By default, the Rust function templates strip names from the binary, and Shopify CLI runs wasm-opt, which also removes them. To profile your function, complete the following steps:

  1. In your Cargo.toml, stop stripping symbols from release builds:

    Cargo.toml

    [profile.release]
    lto = true
    opt-level = "z"
    strip = false
  2. In your shopify.extension.toml, disable wasm-opt:

    shopify.extension.toml

    [extensions.build]
    command = "cargo build --target=wasm32-unknown-unknown --release"
    path = "target/wasm32-unknown-unknown/release/[RUST-PACKAGE-NAME].wasm"
    wasm_opt = false
  3. Rebuild your function so that the new settings take effect, and then run it with the --profile flag, using an input that represents a heavy workload:

    Terminal

    shopify app function build
    shopify app function run --input input.json --profile

    Shopify CLI writes a profile file with a .perf extension to your function's directory. The file is named after your WebAssembly module.

  4. Open Speedscope and load the .perf file. Each sample is weighted by instructions executed, so the widest frames are the functions that execute the most instructions. The Left Heavy view groups identical call stacks together, which makes the most expensive code paths easy to find.

  5. Optimize the most expensive functions, rebuild, and profile again to confirm that the instruction count dropped.

  6. When you're done profiling, restore your original strip and wasm_opt settings. Stripping names and running wasm-opt keep your deployed binary small.

Anchor to Optimize with an AI agentOptimize with an AI agent

AI coding agents can work through a profile and try optimizations much faster than you can by hand. Because instruction counts are deterministic, an agent can measure every change it makes and keep only the ones that help. To get good results, give the agent a way to check both correctness and performance:

  • Shopify context: Install the Shopify AI Toolkit so that your agent has current guidance for Shopify Functions and Shopify CLI.
  • Test fixtures: A set of fixtures that cover your real workloads, so that the agent can confirm the function output doesn't change. For more information, refer to Add additional test fixtures.
  • An instruction count test: A test that reports instruction counts for each fixture, as described in Track instruction counts in your tests.
  • A profile: The .perf file from profiling your function. It's a plain text file, so agents can read it directly.

For example, you might give your agent a prompt like the following:

Example prompt

Reduce the instruction count of the Shopify Function in extensions/my-function.

- Run `npm test` in the function directory to check correctness and to see
the instruction count for each fixture. Every fixture must keep producing
the same output.
- Run `shopify app function build`, and then run
`shopify app function run --input input.json --export <export> --profile`
with the input from a large fixture to generate a profile. Read the .perf
file to find the most expensive code paths.
- Make one change at a time, rebuild, and measure. Keep only changes that
reduce instruction counts.
- Keep the compiled WebAssembly binary under 256 kB.
- When you're done, summarize each change and its effect on instruction counts.

Review the agent's changes as you would any other code change before you deploy.


Anchor to Track instruction counts in your testsTrack instruction counts in your tests

Functions generated from a template include integration tests that use the @shopify/shopify-function-test-helpers package. In version 1.1.0 and higher, the runFunction helper returns a metadata object with measurements for each run:

PropertyDescription
instructionCountThe number of WebAssembly instructions that the function executed.
memoryUsageKiBThe memory that the function used, in kibibytes.
moduleSizeKiBThe size of the compiled WebAssembly module, in kibibytes.

You can use these measurements to build your own reports, or to fail your tests when a change increases the instruction count. For more information about integration tests and fixtures, refer to Writing Wasm integration tests for functions.

Anchor to Set an instruction count baselineSet an instruction count baseline

The following test requires version 1.1.0 or higher of the test helpers. If your function uses an earlier version, then upgrade it from your function's directory:

Terminal

npm install --save-dev @shopify/shopify-function-test-helpers@^1.1.0

The following version of the default integration test keeps its existing checks and adds an instruction count baseline. For every fixture in tests/fixtures/, it validates the fixture, checks the output, prints a table of instruction counts, and fails if a fixture uses more than 5% more instructions than its recorded baseline. Replace the contents of tests/default.test.js with it:

tests/default.test.js

import path from "path";
import fs from "fs";
import { describe, beforeAll, afterAll, test, expect } from "vitest";
import {
buildFunction,
getFunctionInfo,
loadSchema,
loadInputQuery,
loadFixture,
validateTestAssets,
runFunction,
} from "@shopify/shopify-function-test-helpers";

const BASELINE_PATH = path.join(__dirname, "instruction-count-baseline.json");
const UPDATE_BASELINE = process.env.UPDATE_INSTRUCTION_BASELINE === "1";
// Allow for small differences between toolchain versions.
const TOLERANCE = 1.05;

const baseline = fs.existsSync(BASELINE_PATH)
? JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"))
: {};
const measured = {};

describe("Default Integration Test", () => {
let schema;
let schemaPath;
let targeting;
let functionRunnerPath;
let wasmPath;

beforeAll(async () => {
const functionDir = path.dirname(__dirname);
await buildFunction(functionDir);
({ schemaPath, functionRunnerPath, wasmPath, targeting } =
await getFunctionInfo(functionDir));
schema = await loadSchema(schemaPath);
}, 45000);

afterAll(() => {
console.table(measured);
if (UPDATE_BASELINE) {
fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(measured, null, 2)}\n`);
}
});

const fixturesDir = path.join(__dirname, "fixtures");
const fixtureFiles = fs
.readdirSync(fixturesDir)
.filter((file) => file.endsWith(".json"));

fixtureFiles.forEach((fixtureFile) => {
test(`runs ${fixtureFile}`, async () => {
const fixture = await loadFixture(path.join(fixturesDir, fixtureFile));
const queryPath = targeting[fixture.target].inputQueryPath;
const inputQueryAST = await loadInputQuery(queryPath);

const validationResult = await validateTestAssets({
schema,
fixture,
inputQueryAST,
});
expect(validationResult.inputQuery.errors).toEqual([]);
expect(validationResult.inputFixture.errors).toEqual([]);
expect(validationResult.outputFixture.errors).toEqual([]);

// Use the export from shopify.extension.toml, so that the same fixtures
// work if you switch your function between languages.
const runResult = await runFunction(
{ ...fixture, export: targeting[fixture.target].export ?? fixture.export },
functionRunnerPath,
wasmPath,
queryPath,
schemaPath,
);
expect(runResult.error).toBeNull();
expect(runResult.result.output).toEqual(fixture.expectedOutput);

const { instructionCount } = runResult.metadata;
measured[fixtureFile] = instructionCount;

if (UPDATE_BASELINE) return;

expect(
baseline[fixtureFile],
`No baseline for ${fixtureFile}. Run with UPDATE_INSTRUCTION_BASELINE=1.`,
).toBeDefined();
expect(instructionCount).toBeLessThanOrEqual(
Math.ceil(baseline[fixtureFile] * TOLERANCE),
);
}, 10000);
});
});
Note

Keep the baseline checks in the same test file as your other integration tests. Each test file that calls buildFunction() starts its own build, and Vitest runs test files in parallel, so separate files can build the same function at the same time.

To record the baseline, run the tests with the UPDATE_INSTRUCTION_BASELINE environment variable, and commit the generated tests/instruction-count-baseline.json file:

Terminal

UPDATE_INSTRUCTION_BASELINE=1 npm test

After that, npm test fails whenever a change pushes a fixture's instruction count above its baseline. When you make an optimization, record the baseline again so that your improvement becomes the new standard.

Info

Run your function's tests in your continuous integration pipeline to catch instruction count regressions before you deploy. Make sure that your fixtures include your largest realistic carts, because that's where instruction counts grow the most.



Was this page helpful?