Skip to main content
NEURAL X CIPHER official logo
Back to Engineering Insights
Flutter & SDK Engineering9 min read|Updated 2026-11-11

Inside shopify_pro_sdk: Engineering a Two-Tier Cached GraphQL Client for Headless Commerce in Dart 3

Why we replaced heavy third-party GraphQL libraries with a zero-bloat HTTP transport, L1/L2 hybrid caching, exponential backoff with jitter, and Dart 3 sealed exceptions.

Abrar Ali
Founder, CEO & Principal Software Architect
ORCID: 0009-0000-5688-0631
ARCHITECTURAL DIRECT ANSWER (TL;DR)

To achieve sub-300ms catalog browsing in headless Shopify Flutter apps without suffering transitive dependency conflicts, shopify_pro_sdk implements a custom GraphQL client directly over package:http, pairs an in-memory LRU cache (L1) with a persistent SharedPreferences TTL cache (L2), applies exponential backoff with random jitter on 429/5xx responses, and exposes a Dart 3 sealed ShopifyException hierarchy for exhaustive compile-time error handling.

KEY ENGINEERING TAKEAWAYS:
  • Eliminating heavyweight GraphQL client dependencies prevents version lockups across large commercial Flutter codebases.
  • A two-tier cache (L1 in-memory LRU + L2 persistent TTL) serves repeat product and collection reads in under 5ms while surviving app restarts.
  • Exponential backoff with full jitter prevents synchronized retry storms when mobile networks recover or Shopify rate-limits bursts.
  • Dart 3 sealed exception classes force consuming UI layers to handle network, throttling, and GraphQL userErrors deterministically.
Inside shopify_pro_sdk: Engineering a Two-Tier Cached GraphQL Client for Headless Commerce in Dart 3

1. Why Existing Flutter Shopify Wrappers Fall Short in Production

While building mobile commerce apps at neuralXcipher, we kept hitting the same three problems with off-the-shelf Shopify wrappers for Flutter: fragile transitive dependencies, no built-in caching, and errors thrown as untyped exceptions.

The dependency problem shows up first. A general-purpose GraphQL client pulls in its own HTTP layer, cache store, code generator and normalisation logic. In a small app that is fine. In a commercial codebase with payments, analytics, maps and push notifications, one package pinning an old version of a shared dependency can block an upgrade of the whole project for weeks.

The caching problem shows up in the numbers. A shopper who taps from a collection grid into a product and back again is asking for data the app already had two seconds ago. Re-fetching it over a cellular connection costs battery and patience, and slow collection screens are where shoppers drop off before they ever reach the cart.

The error problem shows up in support tickets. When every failure is a generic Exception, the UI cannot tell a dropped connection from a rate limit from a checkout validation error, so it shows the same "Something went wrong" for all three.

Open-Source Verification

shopify_pro_sdk (v1.1.0) is published under our verified publisher neuralxcipher.com on pub.dev. The code samples in this article are simplified from the package to show the design, not copied line for line.

2. A GraphQL Transport in One File

The Shopify Storefront API is a single HTTPS endpoint that accepts a POST with a JSON body. That is the whole protocol. A storefront app sends a known set of queries and mutations that the developer wrote by hand, so it does not need a normalised cache, schema introspection or generated request classes.

So the SDK talks to Shopify with package:http and nothing else. The transport takes a query document and a variables map, adds the storefront access token header, and returns the decoded data object or throws a typed error.

The whole transport: one POST, two headers, typed failuresdart
Future<Map<String, dynamic>> _post(
  String document,
  Map<String, dynamic> variables,
) async {
  final response = await _http
      .post(
        Uri.https(shopDomain, '/api/$apiVersion/graphql.json'),
        headers: {
          'Content-Type': 'application/json',
          'X-Shopify-Storefront-Access-Token': storefrontToken,
        },
        body: jsonEncode({'query': document, 'variables': variables}),
      )
      .timeout(requestTimeout);

  if (response.statusCode == 429) {
    throw ShopifyRateLimitException(
      'Storefront API throttled the request',
      retryAfter: _parseRetryAfter(response.headers),
    );
  }
  if (response.statusCode >= 500) {
    throw ShopifyNetworkException(
      'Shopify returned a server error',
      statusCode: response.statusCode,
    );
  }

  final body = jsonDecode(response.body) as Map<String, dynamic>;
  final errors = body['errors'] as List<dynamic>?;
  if (errors != null && errors.isNotEmpty) {
    throw ShopifyGraphQLException(
      'GraphQL request failed',
      errors: [for (final e in errors) e['message'] as String],
    );
  }
  return body['data'] as Map<String, dynamic>;
}

Everything else in the SDK sits on top of this function. Because it has one job, it is easy to test with a fake HTTP client, and adding a new Storefront API version is a one-line change.

3. Cache Keys That Cannot Collide

A cache is only as good as its keys. Two requests must share a key when, and only when, Shopify would return the same payload for both. Four things decide the payload: the operation, the query text, the variables, and the market context passed through the @inContext directive, which controls currency and language.

Leaving out the market context is the classic bug. A shopper who switches from Germany to Australia would keep seeing euro prices from the cache. Leaving variable order unhandled is the quieter one: {first: 20, after: null} and {after: null, first: 20} are the same request, but naive string keys treat them as different and the hit rate drops.

The SDK sorts variable keys recursively before encoding them, then hashes the combined string so keys stay short enough for persistent storage.

Building a canonical key from query, sorted variables and marketdart
String _buildCanonicalKey(
  String document,
  Map<String, dynamic> variables,
  MarketContext market,
) {
  final normalised = jsonEncode({
    'q': document.replaceAll(RegExp(r'\s+'), ' ').trim(),
    'v': _sortKeys(variables),
    'm': '${market.countryCode}-${market.languageCode}',
  });
  return sha1.convert(utf8.encode(normalised)).toString();
}

Object? _sortKeys(Object? value) {
  if (value is Map<String, dynamic>) {
    final sorted = SplayTreeMap<String, dynamic>.from(value);
    return {for (final e in sorted.entries) e.key: _sortKeys(e.value)};
  }
  if (value is List) return value.map(_sortKeys).toList();
  return value;
}

4. Designing the Two-Tier (L1 LRU + L2 Persistent TTL) Cache

One cache medium cannot be both instant and durable, so the SDK uses two. Tier 1 (L1) is an in-memory Least-Recently-Used map. It answers back-navigation and tab switches synchronously, in well under a millisecond, and it has a fixed capacity so a long browsing session cannot grow it without limit.

Tier 2 (L2) writes each response as a JSON envelope with an expiry timestamp into SharedPreferences. It is slower than memory but survives an app restart, which means a returning shopper sees featured collections immediately instead of a spinner.

A read tries L1, then L2, then the network. An L2 hit is copied up into L1 so the next read is free. A network response is written to both tiers.

Simplified two-tier read-through cache resolution in shopify_pro_sdkdart
Future<Map<String, dynamic>> executeQuery({
  required String document,
  Map<String, dynamic> variables = const {},
  CachePolicy policy = CachePolicy.cacheFirst,
  Duration ttl = const Duration(minutes: 10),
}) async {
  final cacheKey = _buildCanonicalKey(document, variables, marketContext);

  if (policy == CachePolicy.cacheFirst) {
    // 1. Check L1 In-Memory LRU (<1ms)
    final memoryHit = _l1MemoryCache.get(cacheKey);
    if (memoryHit != null) return memoryHit;

    // 2. Check L2 Persistent TTL Storage (<5ms)
    final diskHit = await _l2DiskCache.getIfFresh(cacheKey);
    if (diskHit != null) {
      _l1MemoryCache.put(cacheKey, diskHit); // Promote to L1
      return diskHit;
    }
  }

  // 3. Execute Resilient Network Request with Backoff
  final freshData = await _executeWithRetry(document, variables);
  _l1MemoryCache.put(cacheKey, freshData);
  await _l2DiskCache.put(cacheKey, freshData, ttl: ttl);
  return freshData;
}

The L1 map itself is a few lines. Dart's default Map keeps insertion order, so removing a key and inserting it again moves it to the most-recent end, and the first key is always the eviction candidate.

5. Choosing TTLs and Knowing What Never to Cache

A cache that serves a wrong price is worse than no cache. The rule we follow is that the time-to-live should match how often the merchant actually changes that kind of data, and anything tied to money or identity is never cached at all.

  • Shop settings, menus and policies: hours. They change a few times a year.
  • Collection lists and featured products: around ten minutes. Long enough to make browsing instant, short enough that a new launch appears quickly.
  • Product detail with price and availability: one to five minutes, and always refreshed from the network before add-to-cart.
  • Search results: short or not at all, because the key space is unbounded and hit rates are low.
  • Cart, checkout, customer and order data: never cached. These go to the network every time.

Each call picks a policy. cacheFirst is the default for catalogue reads. networkFirst suits a product screen where stale stock is costly but an offline fallback is still useful. networkOnly is for mutations and anything in the checkout path. Pull-to-refresh simply repeats the call with networkFirst, which also rewrites both tiers.

On flash-sale days the merchant can shorten TTLs remotely through app config, so the app trades a little speed for fresher stock counts exactly when it matters.

6. Exponential Backoff with Jitter

Mobile connections fail in bursts. A phone leaving a lift or moving from Wi-Fi to cellular will fail several requests at once, and if all of them retry immediately they collide again. Across thousands of devices, synchronised retries can also keep a throttled API throttled.

The transport retries only failures that can succeed on a second attempt: timeouts, socket errors, HTTP 429 and 502/503/504. It waits baseDelay * 2^attempt, capped at a maximum, and then picks a random delay between zero and that value. The randomness is what spreads devices apart. When the server sends a Retry-After header, that value wins.

Retry loop with capped exponential backoff and full jitterdart
Future<Map<String, dynamic>> _executeWithRetry(
  String document,
  Map<String, dynamic> variables,
) async {
  var attempt = 0;
  while (true) {
    try {
      return await _post(document, variables);
    } on ShopifyRateLimitException catch (e) {
      if (attempt >= maxRetries) rethrow;
      await Future<void>.delayed(e.retryAfter);
    } on ShopifyNetworkException {
      if (attempt >= maxRetries) rethrow;
      final ceiling = min(
        maxDelay.inMilliseconds,
        baseDelay.inMilliseconds * pow(2, attempt).toInt(),
      );
      await Future<void>.delayed(
        Duration(milliseconds: _random.nextInt(ceiling + 1)),
      );
    }
    attempt++;
  }
}

GraphQL errors are deliberately not retried. A malformed query or an invalid discount code will fail the same way every time, and retrying it only delays the message the shopper needs to see.

7. Dart 3 Sealed Exceptions

Every failure the SDK can produce is a subclass of one sealed class. Sealed classes tell the Dart compiler the full list of subtypes, so a switch over a ShopifyException must handle each one or the code does not compile.

That turns error handling from a convention into a guarantee. When we add a new failure type in a later release, every app that upgrades gets a compile error at each place that needs a decision, instead of a silent fall-through at runtime.

Exhaustive Dart 3 pattern matching with sealed ShopifyExceptiondart
sealed class ShopifyException implements Exception {
  const ShopifyException(this.message);
  final String message;
}

final class ShopifyNetworkException extends ShopifyException {
  const ShopifyNetworkException(super.message, {this.statusCode});
  final int? statusCode;
}

final class ShopifyRateLimitException extends ShopifyException {
  const ShopifyRateLimitException(super.message, {required this.retryAfter});
  final Duration retryAfter;
}

final class ShopifyGraphQLException extends ShopifyException {
  const ShopifyGraphQLException(super.message, {required this.errors});
  final List<String> errors;
}

// In a Riverpod notifier or widget:
String describe(ShopifyException error) => switch (error) {
  ShopifyNetworkException() => 'You appear to be offline. Showing saved items.',
  ShopifyRateLimitException(:final retryAfter) =>
    'Busy right now. Retrying in ${retryAfter.inSeconds}s.',
  ShopifyGraphQLException(:final errors) => errors.first,
};

In practice the network case shows cached content with an offline banner, the rate-limit case shows nothing at all because the retry loop absorbs it, and the GraphQL case surfaces Shopify's own message, which is usually exactly what the shopper needs to read.

8. What This Buys You, and When to Use It

With both tiers warm, a repeat collection or product read resolves in under 5ms, and catalogue browsing stays under 300ms in the apps where we have shipped it. A cold start shows cached featured collections before the first network response arrives. The app also keeps working as a read-only catalogue when the connection drops.

The same pattern is not specific to Shopify. We use the stale-while-revalidate variant of it against the WooCommerce REST API, described in our WooCommerce performance write-up, and it underpins the WooStore white-label suite.

If you are building a headless Shopify app in Flutter, you can install the package from pub.dev and read the full shopify_pro_sdk case study. If you would rather have the storefront app built for you, that is our Shopify and WooCommerce mobile app service, and we also build custom SDKs and packages for teams that need their own.

// READY TO IMPLEMENT THIS ARCHITECTURE?

Book a Free Strategy Call

Speak directly with Principal Software Architect Abrar Ali on architecture, timeline, and fixed-scope delivery.

Book a Free Strategy Call