↖ back to projects

engineering fieldnotes / 03

metafields
& metaobjectssmall data.
very many reads.

What I shipped across Shopify’s custom-data stack: a cache for billions of Functions reads, callback deduplication, a capabilities API, and repository cleanup.

RubyGraphQLMemcached
fig. 01 / a shorter path↓ follow a read
GraphQL loaderdirect loader
one shared boundaryshort-lived readerauthorize · resolve · reconstruct
local memoryreuse within a request
Memcachedreuse across requests
5s
read replicaonly the remaining misses

cache hits return early.
misses still go to the read replica.

Four projects from my software engineering internship on Shopify’s Metafields team.

I worked across performance, API design, reliability, and repository health in a large Ruby and GraphQL codebase.

~81%of ~10.8B daily reads
served by the cache
1,491always-on machines’ worth
of compute saved
−18% callback invocations
32 → 1manual frontend implementations
replaced by one GraphQL endpoint

Cache volume and hit-rate figures are approximate. Diagrams use made-up identifiers and timings; no merchant data or literal API responses are shown.

my role

Software engineering intern on the Metafields team, May–August 2026.

what I worked in

Ruby · GraphQL · SQL · Memcached · read replicas · feature flags.

01 / my time at Shopify

working on custom data at scale

I joined Shopify’s Metafields team as a software engineering intern. Metafields attach typed custom data to products, orders, and other resources; metaobjects group fields into reusable entries. My work touched the read path, callback lifecycle, GraphQL API, and the team’s bug backlog.

fig. 02 / the vocabularyschematic example
metafield

one value, attached to an owner

owner
product / 42
namespace
shipping
key
fragile
typed value
true boolean
metaobject

a structured custom entry

entry type
care guide
title
keep it looking good
instructions
hand wash gently
illustration
image reference
Made-up examples for the data model. No merchant data or literal API responses are shown.
02 / project 01 · Checkout cache

one reader for two Checkout paths

I built a two-tier, Memcached-backed cache that served about 81% of roughly 10.8 billion daily reads across Shopify’s two largest Functions loaders, reducing mean latency from 6.5 ms to 2 ms per read.

The two loaders reached the same metafield data through different paths. The batched GraphQL loader already used a five-second cache. The direct namespace-and-key loader—used in roughly one-third of runs—went to the database every time.

I reused the existing cache design so both loaders could share authorization, keying, caching, and result reconstruction.

fig. 03 / remove the asymmetrybefore → after

two paths, different costs

GraphQL loader↓existing 5s cache↓ missesread replica
direct loader↓ every readread replica

Repeated direct reads keep making the trip.

two callers, one reader

GraphQLdirect
short-lived reader
local→ missshared→ missreplica

Both paths now run the same permission and result handling code.

The database remains the source of truth. The reader only keeps repeated requests from reaching it unnecessarily.
implementation

follow one read through the system

I extracted the existing cache into a shared list-in/list-out reader, then routed the direct loader through it. The reader checks permissions, request memory, Memcached, and finally the replica—while returning the same ordered entities each caller already expected.

fig. 04 / request laboratoryinteractive · illustrative

three identifiers, six stops

Change the starting cache state, then follow which identifiers make it as far as the database. The example is synthetic.

01authorizecheck readable scope
02keyinclude identity + version
03localrequest-scoped memory
04sharedMemcached · 5s TTL
05replicabatch remaining misses
06returnrebuild caller results
ready / cold cache

Starting state: cold cache. Press play, or step through one stop at a time.

—local answers
—shared answers
—identifiers sent to DB
—DB query batches
Caller-facing results / cold-read example
identifieranswer sourcecaller receives
custom.colourread replica on a cold missmetafield entity
custom.sizeread replica on a cold missmetafield entity
custom.noteread replica on a cold missmetafield entity
Local hits stop immediately. Memcached hits avoid the database. A cached “not found” also counts as an answer.
consistency

five seconds, two consistency rules

Request-local memory avoids repeated work inside one request; Memcached reuses results across workers for five seconds. A normal write increments a shop-level version so new reads move to a new key immediately.

One catch: writes to metaobject-owned fields didn’t bump the shop version. The cache could hold an old value—or a “not found” result—for up to five seconds. A miss could also reach a replica that was still catching up.

correctness

edge cases that changed the implementation

A mapping bug is worse when the wrong answer gets cached. Most of the failures I had to cover came from ordinary inputs looking different in Ruby, MySQL, and the serialized cache payload.

01 collation + negative caching

MySQL can treat two spellings as equivalent while Memcached sees different strings. I mapped returned rows back using database-equivalent matching so one request could not receive a false cached absence.

02 bytes, not characters

The 10,000-byte value limit needed Ruby’s bytesize, plus boundary and multibyte tests. Oversized values receive the private absence marker.

“a” → 1 UTF-8 byte“ü” → 2 UTF-8 bytes“🌿” → 4 UTF-8 bytes
03 authorization on every route

The reader rejects unreadable identifiers before lookup and checks returned rows again. A warm cache cannot become a shortcut around permissions.

04 every promise settles

I mapped partial, missing, and duplicate results back to the original batch so every GraphQL promise resolved exactly once.

shipping

rolling out each loader separately

I separated the reader extraction from the new caller adoption. The existing cached path moved first, behind a per-shop flag that started at zero percent. The direct loader received its own flag later. If one caller misbehaved, I could move only that caller back.

Tests covered partial hits, cached absences, duplicate identifiers, byte limits, permissions, legacy values, both callers, and collation in both directions. Cross-worker single-flight stayed out of scope, so the gradual ramp also protected the replica from cold-cache bursts.

03 / project 02 · performance

callback optimization

I deduplicated metafield callback invocations within each request across six resources, including Product and Order. That removed 18% of all invocations and saved compute equivalent to 1,491 always-on machines.

fig. 05 / callback invocationsnormalized baseline
before
100
after
82
−18%
1,491always-on machines’ worth of compute saved−18% of all callback invocations
The chart sets the old callback-invocation count to 100 and shows the measured 18% reduction. It does not represent request count or elapsed time.
04 / project 03 · API design

metafield capabilities API

I built a GraphQL endpoint for querying metafield capabilities and extended it to expose theme-referenced sources. It deprecated all 32 manually maintained frontend implementations.

I designed the API, permission checks, and resolver, then moved consumers over so the duplicated logic could be retired.

fig. 06 / consolidate the source of knowledgecontract-level view

before / 32 places to maintain

0102030405060708091011121314151617181920212223242526272829303132

Each frontend implementation kept its own capability logic.

after / one capability contract

GraphQL endpointmetafield capabilitiesAPI shape + authorization + resolver
frontend consumerstheme-referenced sources exposed

Consumers moved to the API and the duplicated logic could be retired.

This is an ownership diagram, not the literal GraphQL schema.
05 / project 04 · reliability

repository cleanup

Across the metafield repositories, I fixed 17 bugs spanning null errors, orphaned metafields, and slow SQL queries. That covered 33% of the team’s small, prioritized high-severity issues and removed more than 3,500 lines of code.

17bugs fixed
3,500+lines removed
33%of small, prioritized
high-severity issues

— ashlyn lee · end of fieldnotes.