The Architecture of skowt.cc

27 min read

skowt.cc started as a directory listing. An h5ai file index I'd spun up overnight at wtf.dromzeh.dev on a DigitalOcean instance, with no real frontend on top of it. A couple of days after I left one TikTok comment pointing at it, traffic exploded, and all at once a lot of people were picking through a raw file listing that had no way to search.

I'd built for the web before this: a popular anonymous file-sharing service written in Flask that I ran for a while, then took down. So a production deploy wasn't new to me, SvelteKit was. This was before you could ask an AI for help, and Stack Overflow wasn't somewhere I wanted to be, so I taught myself it in three days by trying things, watching them break, and trying again. That version became wanderer.moe, and wanderer.moe became skowt.cc: one site the whole way through, two renames and a couple of rewrites apart. I've only ever run one asset database. This is its current shape.

What it does hasn't changed. People upload assets from popular games, a lot of them gacha games, and for now it's all images. Other people pull them back down, often a few hundred in one go, zipped together. Expanding past images is on the roadmap, but the pressure is already there. The traffic is spiky and downloads arrive in big batches, so most of the design comes down to one bias: keep the request path cheap, and never make the API do work it can hand to something else.

Layout#

The whole thing is a monorepo. Two apps, a handful of workspace packages. apps/web is a TanStack Start SPA that ships its own server bundle for SSR. apps/server is an Elysia process that mounts a tRPC adapter and a couple of health endpoints. Everything the apps don't own lives under packages/*: the routers, the better-auth config, the Drizzle schema, the env validation, the observability stack.

The frontend talks to the backend through one typed tRPC client and nothing else. There's no hand-written fetch wrapper, no REST layer, no generated client to keep in step with the server. The router types flow straight through, so a procedure signature change is a red squiggle in the browser code before it's a runtime error.

Bun and Elysia#

The server runs on Bun, not Node. Startup and raw throughput are the obvious wins, but the part I use most is the plugin model. Elysia exposes the request as it moves through named lifecycle hooks (onRequest, derive, onAfterHandle, onAfterResponse), so the whole logging and tracing layer hangs off those hooks instead of wrapping every handler in middleware. I write the procedure and the plugin underneath does the logging, I never wire it in by hand.

tRPC sits on top as the API surface: end-to-end types, no codegen, no schema to keep in sync. The client and server can't disagree about a shape, so that whole class of bug just isn't there.

Data Layer#

Three stores, each holding the thing it's best at.

The relational data lives in Turso, which is hosted libSQL: an open-source fork of SQLite that adds a server you talk to over the network. That choice pays off twice later: search can be a virtual table in the same database rather than a separate service, and it's also the reason a naive session check hurts, because every read is a network round-trip rather than a local file seek.

Files live in Cloudflare R2, fronted by a public CDN at pack.skowt.cc. Redis holds the ephemeral state: sessions, rate-limit windows, download batches, and a cache in front of the hot read paths. Logs and traces go to Better Stack.

The thing to hold onto is which store owns what. Durable catalogue data is Turso, bytes are R2, and anything short-lived or per-user is Redis.

Upload Path#

The biggest single decision: the API process never handles file bytes, on the way in or out. It moves metadata and URLs, and the bytes go straight between the browser and R2.

Presigned Handshake#

An upload is three steps. First the browser calls uploads.requestUpload with the file's name, MIME type, size, and where it belongs in the catalogue. The server checks the MIME type against a MIME_TO_EXTENSION map, rejects anything over MAX_FILE_SIZE, inserts the asset row as pending, and mints a presigned PUT URL. Then the browser uploads straight to R2 against that URL. Finally it calls uploads.commitUpload, and only then does the server confirm the file and flip its status.

A large upload never streams through Elysia. It doesn't sit in the process's memory or count against its concurrency, so the process stays small and its latency stays flat, whether the file is a few megabytes today or one of the bigger packs the catalogue will take once it grows past images. The presigned URL carries a short expiry, so the grant is bounded in time as well as scope.

Distrusting the Upload#

A presigned PUT pins almost nothing: not the Content-Type, not the byte count. So the browser can declare a 2MB PNG at requestUpload and then push 400MB of arbitrary bytes to the URL, and nothing it declared up front is binding.

So commitUpload treats the uploaded object as hostile and re-derives the truth. It stats the object, which is the only real size enforcement in the whole flow, and rejects anything over MAX_FILE_SIZE before reading a byte. It reads the header bytes and rejects anything that isn't actually an image, because the earlier Content-Type check guaranteed nothing. Only an object that passes both gets its status flipped. Storage keys derive from the asset id, never from the file hash; the hash is a content fingerprint recorded after the fact, and keying storage on it would break the moment two users upload the same bytes.

Limbo Prefix#

The presigned URL targets one of two prefixes. Trusted uploads, meaning developers who pass a shouldSkipQueue(role) check, write straight to the public asset/ prefix. Everyone else writes to limbo/, which the CDN can't see.

That split is the whole moderation model, and it's enforced by storage layout rather than by an access check I have to remember to write. The instant an object exists under asset/, anyone who can build the URL can download it. A pending upload has to physically sit somewhere the CDN won't serve until a moderator clears it. Approval is the operation that copies the object from limbo/ to asset/ and flips the row to approved. That invisibility comes from where the bytes sit, not from a check on the request that I could forget to write.

Download Path#

Downloads run the same idea in reverse, and give the API even less to do.

Direct From the CDN#

There's no presigned-download flow at all. Cloudflare serves each file from R2 directly, and the card thumbnails are pre-generated {id}-thumb.webp objects the ingest pipeline builds ahead of time, not transforms computed per request. When you download a batch, the API verifies the asset ids against the database, bumps each asset's downloadCount, writes a batch record to Redis, and returns a batch id. The browser reads that batch back and fetches each file straight from the CDN, so none of the actual download traffic touches the API.

Being blunt about the trade: the API's role here is accounting, not gatekeeping. The CDN URL is constructible from the public asset id and extension, both of which sit in every query response, so anyone who wants to can fetch a file directly and skip the counter. Gating the bytes would mean a presigned-download flow or a worker in front of R2. I decided the counter earns its place even when it's skippable, and I'd rather write the limitation down than pretend it isn't one.

Download Batches in Redis#

A batch is short-lived, per-user, and disposable, which is exactly the shape Redis is good at. recordDownloadBatch mints a uuidv7 for the batch id, so ids sort by creation time for free. It rejects any payload over 512KB, then runs a pipeline: set the batch JSON with a 90-day expiry, zadd the id into a per-user sorted set scored by the current time, and refresh that set's own expiry. A per-user cap of 50 batches keeps it bounded; past 50, it reads the oldest ids off the front of the sorted set, deletes their data keys, and trims the set by rank.

Reads lean on the same structure. Listing a user's batches is one zrevrange for the page and a single mget for all the payloads at once. If a payload has expired out from under its sorted-set entry, that read treats the entry as corrupt and drops it, so the index heals itself instead of accumulating dead ids. Fetching one batch does a zscore ownership check first, so a user can't read a batch id that isn't theirs. None of this needs a table, a migration, or a background cleanup job. The keys expire on their own.

Catalogue Query#

The catalogue query is the hot path. It runs on every game page, every filter change, every sort, every scroll. It has to stay cheap on a library with a long tail, so two things carry it: a cache, and keyset pagination.

Caching Public Reads#

Every result is keyed by its full parameter set and cached for two minutes, in Redis with an in-memory fallback if Redis is unreachable. The fallback is a bounded Map that evicts the oldest fifth when it fills, so a Redis outage degrades the cache instead of unbounding memory.

What makes the cache safe is that everything under these keys is public, status = 'approved' data. The key builder truncates long parameters, which means two different queries can technically collide on one key. That's fine here precisely because a collision returns someone else's public search results, not anyone's private data. The comment in that file is a warning to my future self: cache per-user or non-approved data through this path and the same truncation becomes a cross-user leak, so hash the full parameter set if that day ever comes.

Invalidation is the part I got wrong first. The catalogue is cached under six namespaces: search, related, recent drops, game landing pages, sitemaps, and site stats. Early on, an approval only cleared the search cache, so a freshly approved asset showed up in search at once but stayed missing from the drops and landing pages for the full two minutes. Now any content mutation clears all six through one function, and structural changes to games or categories clear the filters cache on top. The key names live in one registry so the write side and the invalidate side can't drift apart.

Keyset Over OFFSET#

The naive way to paginate is LIMIT n OFFSET m. It has two problems. The database walks and throws away every row before the offset, so deep pages get linearly slower, and any insert between two page loads shifts every row down, handing you a duplicate or a gap at the boundary.

Keyset pagination fixes both. Instead of counting rows to skip, the cursor pins an exact boundary row, and the next page is everything ordered past it. The query resumes with a tuple comparison, ordered by the sort column with the asset id as a tiebreaker so the ordering is total:

or(
  compareOp(asset.createdAt, cursorValue),
  and(eq(asset.createdAt, cursorValue), compareOp(asset.id, cursorId)),
)

It fetches one row past the limit as a has-more probe and trims it off. Name sorts run COLLATE NOCASE on both the comparison and the ORDER BY, which matters more than it reads: the library still carries slugs from an older wanderer.moe era, and under binary collation those lowercase legacy names sort after the entire uppercase alphabet, splitting the catalogue into two visible blocks.

Cursor#

The cursor carries almost nothing. Only the boundary asset's id and the sort key it belongs to, base64url-encoded over a JSON payload:

export function encodeCursor<T extends Record<string, unknown>>(payload: T): string {
  return Buffer.from(JSON.stringify(payload)).toString("base64url");
}

The sort value itself never goes on the wire. Sort by downloads and the download count stays server-side. On the next request the server re-derives the boundary row's sort value from its id, with a lookup that also re-checks status = 'approved', so a hand-edited cursor can't point at a pending asset and use it as a paging anchor. Decoding is defensive by construction: a malformed, truncated, or tampered cursor comes back as null, which means page one instead of a crash.

Name search is a filter on that same catalogue query, but it's the one filter that needed real machinery underneath it.

FTS5 Trigram Over LIKE#

Name search used to be LIKE '%term%'. That's a full scan, and it's case-sensitive in ways that surprise people. The replacement is an FTS5 virtual table with the trigram tokenizer, living inside the same libSQL database:

CREATE VIRTUAL TABLE IF NOT EXISTS asset_fts
  USING fts5(asset_id UNINDEXED, name, tokenize='trigram')

Trigram was the specific pick because it reproduces the substring feel of LIKE '%term%' while being indexed and case-insensitive. Swapping LIKE for MATCH keeps the behaviour and drops the scan. The one thing trigram can't do is tokenize a term under three characters, so the query layer keeps a LIKE fallback for two-character searches and switches to the index at three:

if (term.length >= 3) {
  return sql`${asset.id} IN (SELECT asset_id FROM asset_fts WHERE name MATCH ${escapeFtsMatch(term)})`;
}
return like(asset.name, `%${term}%`);

That subquery is non-correlated, so MATCH runs once and the planner uses the FTS index instead of materialising an id list into the outer query. Keeping the id list out of the outer query also keeps the bound-parameter count down and the SQL text in traces short.

Keeping the Index in Sync#

The index tracks the asset table through SQL triggers, not application code. Insert, delete, and update triggers mirror rows into asset_fts. The update trigger is scoped AFTER UPDATE OF name on purpose, so the constant view-count and download-count writes never touch the search index. The index stores every asset regardless of status, and the status = 'approved' filter runs at read time, so approving an asset costs nothing at the index.

None of this fits Drizzle. It can't model a virtual table or a trigger, so drizzle-kit push will never create either. The DDL is idempotent, IF NOT EXISTS on everything, and four paths apply it and agree: a hand-written migration, a bun db:fts script, the test harness, and a boot-time check that only backfills when the FTS row count falls behind the asset count, so a normal boot does nothing. Running a separate search service like Meilisearch would mean another process to run and another copy of the data to keep consistent, when the database the catalogue already lives in can do the search itself.

Escaping the Match Grammar#

FTS5's MATCH has its own query language: AND, OR, NEAR, prefix *, column filters, - negation, quoted phrases. Passing raw user input into it would let someone inject an operator, or search for a term with a * in it and get a syntax error back as a 500. The escape wraps the whole term in double quotes and doubles any embedded quote:

function escapeFtsMatch(term: string): string {
  return '"' + term.replace(/"/g, '""') + '"';
}

That forces FTS5 to read every character as literal string content, which for the trigram tokenizer is a plain substring match. Adversarial inputs like " OR x, a*b, and a OR b all collapse to literals.

Sessions in Redis#

Turso is hosted libSQL, so the database sits across the network, and a session check that reads the session row and then the user row is two round-trips at roughly 57ms each. Paying that on every authenticated request taxes everything downstream of it.

better-auth takes a secondary storage adapter, so sessions run through Redis:

const authSecondaryStorage = {
  async get(key) { return authRedis.get(key); },
  async set(key, value, ttl) {
    if (ttl) await authRedis.set(key, value, "EX", ttl);
    else await authRedis.set(key, value);
  },
  async delete(key) { await authRedis.del(key); },
};

Two database round-trips at ~57ms collapse into one sub-millisecond Redis GET. This isn't a dumb TTL cache that can serve a stale session, either: better-auth invalidates the entry itself on sign-out and revocation. Two details earn their comments in the code. authRedis is a dedicated connection, kept off the application Redis client so a burst of per-request auth reads can't head-of-line block the app's own commands. And ioredis wants set(key, value, "EX", seconds), not the node-redis options object; get that wrong and the TTL silently drops, leaking session data past its expiry.

Rate Limiting in Lua#

Rate limiting is the other Redis-native piece, and it's a real sliding window rather than a fixed bucket. Each window is a sorted set where every request is a member scored by its millisecond timestamp. The whole check runs as one atomic Lua script:

redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)  -- evict entries older than the window
local count = redis.call('ZCARD', key)                     -- how many remain
if count < limit then
  redis.call('ZADD', key, now, request_id)                 -- admit this request
  redis.call('PEXPIRE', key, window_ms)                    -- self-expiring key
  return {1, limit - count - 1, 0}
else
  local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
  return {0, 0, math.max(0, oldest[2] + window_ms - now)}   -- when the oldest entry ages out
end

It runs in Lua for atomicity. The trim, the count, and the conditional add have to be one server-side operation. Split them into three application-side Redis calls and two concurrent requests interleave between the ZCARD and the ZADD: both read a count under the limit, both get admitted, and the limit overshoots. Lua runs on the Redis server as a single unit, so that race can't open. On rejection it computes a real retry-after from the moment the oldest in-window request will age out, which the tRPC middleware turns into a TOO_MANY_REQUESTS with the header set. If Redis is unreachable, the limiter falls back to a per-instance in-memory window rather than failing the request.

Limits are per procedure tier: 60 searches a minute, 120 general queries, 30 downloads, 20 uploads, 20 comments, and data export capped at one an hour because it's expensive to serve. The same limiter guards the /api/auth/* routes that better-auth handles outside the tRPC pipeline, keyed per auth path so frequent session reads don't share a bucket with sign-in attempts. The client IP for anonymous keys comes only from Cloudflare's cf-connecting-ip; the spoofable x-forwarded-for and x-real-ip get rejected.

Role Hierarchy#

Authorization is four tiers on a numeric ladder, not a bag of permission flags:

export const ROLE_HIERARCHY = { user: 0, contributor: 1, staff: 2, developer: 3 };
export function hasMinimumRole(userRole, requiredRole) {
  return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
}

The procedures compose as a chain. A base traced procedure opens the per-call span. protectedProcedure builds on it with the session-required check and narrows the session type to non-null. createMinimumRoleProcedure(role) builds on that with a single numeric comparison:

function createMinimumRoleProcedure(requiredRole, message) {
  return protectedProcedure.use(({ ctx, next }) => {
    const userRole = parseUserRole(ctx.session.user.role);
    if (!hasMinimumRole(userRole, requiredRole)) throw new TRPCError({ code: "FORBIDDEN", message });
    return next({ ctx });
  });
}

contributorProcedure, staffProcedure, and developerProcedure are that factory called with a different rung. There's no per-feature flag anywhere. A feature that needs finer gating than the four tiers is a signal to split a role or move the check into the procedure body with a written reason, rather than grow a flag matrix nobody can reason about later.

One Wide Event per Request#

Observability is the part I'm fondest of, because it's what makes the rest debuggable in production without much cost.

What Goes in the Event#

Every request emits exactly one structured event at response time, not a log line per step, built up in place as it moves through Elysia's hooks. It carries the standard OTel HTTP attributes, http.request.method, url.path, http.response.status_code, duration_ms, next to cheap domain identity: the user id, a debug id, which tRPC procedures ran, how many database queries the request made, how many outbound fetches. That turns Better Stack's log explorer into one query surface. "Every request where this user hit a procedure that ran more than 50 database queries" is a single filter, no trace to open. Severity still exists as a field on the event, not as the axis you filter on first.

Counting Without a Request Reference#

The per-request counters, db.queries_count and fetch.calls_count, are the interesting piece. The traced database client and the fetch wrapper are deep in the stack, nowhere near the request object, so threading a counter down to them by hand would be miserable. Instead the plugin opens an AsyncLocalStorage frame on onRequest, and the wrappers bump their counters into whatever frame is active. AsyncLocalStorage follows the request across every await, so the count is correct without anyone passing it around. onAfterHandle reads the totals back onto the event. Then onAfterResponse calls endRequestStats() to clear the frame, so a void doThing() that keeps running after the response can't bump counters into an event that already shipped. A db.queries_count over 50 flags an N+1, and a fetch.calls_count over 10 flags a runaway upstream loop, both without opening a trace.

Dual Drain#

The drain that ships those events composes two sinks, stdout and Better Stack, with per-sink failure isolation. When Better Stack's ingestion is down during an incident, the stdout drain still flushes and the failure logs to stderr with the sink that broke. A logging-backend outage can't take local logging down with it.

Tracing Under Bun#

Getting traces at all under Bun took some patching, because the off-the-shelf OTel auto-instrumentation for fetch and ioredis doesn't fire. Bun's fetch is a native Zig implementation, and the require-in-the-middle hooks those packages hang off aren't wired up for Bun as a runtime, so with the official approach zero spans land. So the observability package patches globalThis.fetch and Redis.prototype.sendCommand directly, made idempotent with Symbol.for markers, and every outbound HTTP call and Redis command produces a client span with the right semantic-convention attributes.

The database client gets the same treatment one level lower. tracedClient wraps the raw libSQL client rather than Drizzle, because that's the leaf async call with a real completion-and-error hook to drive a span's lifetime. It spans COMMIT and ROLLBACK on their own, since those are round-trips to Turso, and without them an 800ms write transaction shows up as a 750ms gap nobody can explain. The recorded SQL is capped at 4KB, because an inArray over thousands of ids renders tens of kilobytes of SQL, and one uncapped attribute can push an OTLP request past Better Stack's body limit and drop the whole batch of spans on the floor.

Redacted Secrets#

Secrets travel wrapped in a Redacted newtype. The wrapped value lives in a WeakMap keyed by the wrapper instance, and the wrapper's toString, toJSON, and Node inspect hook all return the literal <redacted>. It's built with Object.create rather than a real constructor, so even instanceof won't out it; the only way back to the value is Redacted.value(self), which is a single grep-able call site. Secrets enter as plain strings at the env boundary, get wrapped the moment they're handed to a consumer, and move as Redacted from there. Log one by accident and you get the sentinel string, not the secret. As a backstop, the drain replaces any Redacted that reaches an event with the sentinel before it hits a sink.

The mechanism is small. The wrapper's prototype answers every serialization path with the sentinel, and make tucks the real value into the WeakMap, where nothing on the object can reach it:

const SENTINEL = "<redacted>";
const registry = new WeakMap<object, unknown>();
 
const proto = {
  toString() { return SENTINEL; },
  toJSON() { return SENTINEL; },
  [Symbol.for("nodejs.util.inspect.custom")]() { return SENTINEL; },
};
 
export const Redacted = {
  make<A>(value: A): Redacted<A> {
    const instance = Object.create(proto); // no property holds the value
    registry.set(instance, value); // it lives in the WeakMap, keyed by the instance
    return instance;
  },
  value<A>(self: Redacted<A>): A {
    return registry.get(self) as A; // the one intentional way back
  },
};

Every path a secret leaks through routes into one of those three methods. JSON.stringify calls toJSON, console.log and util.inspect hit the inspect hook, and a template literal or String() calls toString. All of them hand back <redacted>. The value was never a property on the object, so there's no field for a spread, a structuredClone, or a stray JSON.stringify to pull it out of. It sits in the WeakMap, and the only key that opens that map is the wrapper instance itself, passed to Redacted.value.

"A single grep-able call site" isn't a figure of speech; a helper enforces it. scripts/check-secrets.ts greps the source for the eight protected env vars (BETTER_AUTH_SECRET, DISCORD_CLIENT_SECRET, the two S3 keys, the Better Stack tokens, the IP-hash key) and fails if any of them appears outside a Redacted.make or Redacted.value call in the same statement, or outside a file on an explicit allowlist. The allowlist in docs/secret-allowlist.txt is short, and every line carries its own justification: the env package's declaration site, plus the seed and provisioning scripts that run outside the env package and can't consume a Redacted. The grep is coarse on purpose and errs toward false positives a human reviews rather than misses a real leak. It looks across a small line window instead of one line, so the formatter can wrap a long ternary without tripping it, and adding an allowlist entry is meant to be a deliberate, reviewed decision, not a way to quiet the guard.

Debug ID#

One thread ties it together. A stable per-browser id, minted into localStorage and sent as x-debug-id on every request. Someone reports that a page was slow, copies their debug id out of settings, and support searches Better Stack for that one value to pull every log and trace from that browser, signed in or not. It's the handle that makes a pile of wide events findable by a person who only knows "it was slow for me."

Frontend Data Layer#

The browser side runs on the same instinct: keep the fast backend feeling fast, and never make the user wait on something the client could have hidden.

Search as You Type#

The search box is a local, responsive input. Every keystroke updates the field with no network anywhere near it. A 120ms debounce sits behind it, and only the settled value commits to the query and the URL. That number is deliberate. People type at roughly 150 to 250ms a character, and the server answers a name search in about 5ms off the FTS5 index, so a longer debounce is dead wait with nothing to hide behind. The old 300ms sat on top of every search as pure added latency. Search only fires at two characters and up, so one stray letter doesn't kick off a query.

What keeps it feeling solid instead of flickery is keepPreviousData. When the committed search or a filter changes, TanStack Query holds the old grid on screen and swaps the new results in underneath once they land. The grid never blanks to a spinner between queries, it just updates underneath.

Infinite Scroll#

Scrolling rides the same keyset cursor the backend hands out. Pages are 100 items, and getNextPageParam just returns the nextCursor off the last page. The trigger is an IntersectionObserver watching an invisible one-pixel sentinel, with a rootMargin of 3000px, so the next fetch fires three or four viewports before the sentinel is anywhere near the screen. A fast flick covers a thousand pixels before a fetch round-trip lands, so the margin has to outrun momentum scrolling rather than walking pace. Paired with 100-item pages, the loading spinner is effectively unreachable. Behind the sentinel sits a runway of skeleton cards, so a momentum scroll carries on into ghost cards instead of stalling at the document floor, and the real page swaps in beneath you mid-flight.

Grid#

Every image box reserves its slot with an aspect-ratio computed from the asset's stored dimensions, falling back to a square when the dimensions aren't known, so a card holds its place before the image loads and nothing reflows when it arrives. object-contain letterboxes the real image inside the reserved box, so nothing crops. Images are loading="lazy", and they point at those pre-generated webp thumbnails, so the grid never decodes a full-size original.

The masonry is hand-rolled for the same reason. CSS multi-column layout re-balances every column when new items append, which would shuffle the cards you were looking at each time a scroll page loaded. Instead the grid partitions items into real column stacks with a greedy shortest-column-first pass, using each card's known aspect ratio to estimate its height. The assignment is deterministic and prefix-stable, so appending a page can only add to the bottoms of columns, never move a card that's already on screen. Because the committed filters and search live in the URL as slugs, opening an asset and hitting back restores the same grid and scroll position instead of throwing you back to the top.

Batched Calls, Cached Reads#

The tRPC client uses httpBatchLink, so several procedure calls fired in the same tick collapse into one HTTP request instead of one round-trip each. TanStack Query holds results with a 60-second stale time, so stepping back to a page you just saw doesn't refetch it. TanStack Router preloads a route's data on intent, so the data for a page is often already in flight by the time the click lands.

Cross-Tab Selection Sync#

The download selection, the cart you fill before pulling a batch, is a Zustand store persisted to localStorage and capped at 350 assets. It stays in sync across open tabs with no websocket and no shared worker. The trick is the browser's own storage event: when one tab writes the selection key, every other tab fires a storage event, and a module-level listener re-reads localStorage into the in-memory store. Select an asset in one tab and the cart updates in the rest, no server round-trip involved. A typeof window guard keeps the whole thing inert during SSR. Only the selection syncs live this way; settings persist but don't rehydrate on the event, because a stale toggle in a background tab does no harm.

Graceful Shutdown#

This last one is small, and you never notice it until it breaks. The server handles SIGTERM as a real drain. A flag flips so the health endpoints start returning 503 and the orchestrator stops sending new traffic, then it stops the server, closes Redis, and flushes OTel before it exits. That flush is the point: without it, the last few seconds of spans die in the in-memory batch processor's queue instead of reaching Better Stack, and those are the traces you most want after a deploy or a crash. The flush is bounded, so a slow exporter can't hang the process past its grace period. So the process drains, flushes what it's still holding, and exits clean. Even the last couple of seconds of logs and spans make it out.