When Server Patterns Break at the Edge
Part 4 of 8 — Series index
The design patterns you've spent a decade internalizing aren't wrong. They're just solving problems that don't exist at the edge, while failing to solve the ones that do.
Patterns encode assumptions about their environment: process lifecycles, memory models, threading, the availability of frameworks, the cost of indirection. When you carry a pattern across an environmental boundary, the assumptions often go with it — and when they don't match the new reality, the pattern inverts from asset to liability.
Four archetypal examples, and how each one transforms when you take it seriously at the edge.
The singleton: from guarantor to memory leak
On the server, a singleton is a contract with the process. One connection pool per JVM. One configuration registry per runtime. The process restarts regularly — on deploys, on autoscaling events, on crashes — and each restart gives you a fresh slate. Memory accumulated by the singleton is bounded by the process's lifetime.
At the edge, there is no process restart. The tab is the process. A user might have your app open for hours, days, or — in my experience with infrastructure consoles — weeks. Whatever a singleton accumulates, it keeps accumulating. Forever.
// BAD — classic server-style singleton
class UserCache {
static instance = null;
static getInstance() {
if (!UserCache.instance) UserCache.instance = new UserCache();
return UserCache.instance;
}
constructor() {
this.cache = new Map(); // never cleared
this.listeners = new Set(); // accumulates forever
window.addEventListener('resize', () => this.recalc()); // pins the singleton
}
}
Three problems here. The cache grows without bound. The listener set grows without bound. And the window listener pins the singleton itself in memory, meaning nothing it references can ever be garbage collected. A user who navigates your SPA for an afternoon ends up carrying around the full history of every screen they've touched.
The fix isn't to abandon the pattern. It's to give the singleton a lifecycle:
class UserCache {
constructor({ maxSize = 100, maxAge = 5 * 60 * 1000 } = {}) {
this.cache = new Map();
this.maxSize = maxSize;
this.maxAge = maxAge;
}
set(id, user) {
this.cache.set(id, { user, ts: Date.now() });
if (this.cache.size > this.maxSize) {
this.cache.delete(this.cache.keys().next().value);
}
}
get(id) {
const entry = this.cache.get(id);
if (!entry) return null;
if (Date.now() - entry.ts > this.maxAge) { this.cache.delete(id); return null; }
return entry.user;
}
destroy() { this.cache.clear(); }
}
Bounded size. Time-to-live per entry. An explicit destroy() for major lifecycle events (logout, navigation, tab suspension). You've kept the singleton convenience and added the one thing the edge demands: a story for cleanup.
When you can, use a WeakMap for cache structures keyed on objects you don't own. The garbage collector cleans up entries automatically when the key object is no longer referenced elsewhere — a small, zero-effort memory safety net.
Dependency injection: death by a thousand abstractions
Server-side DI frameworks — Spring, Guice, NestJS — make sense in a world with long startup times, reflection, and abundant memory. You pay the cost of wiring once, at boot, and the pattern pays you back for the lifetime of the process.
At the edge, "boot" happens every page load. Every time the user opens your app, they download the framework and pay the wiring cost — the container initialization, the decorator metadata resolution, the dependency graph traversal. On a slow phone, on a bad network, this is measurable user pain.
The adaptation is to keep the principle of DI — dependencies are explicit, substitutable, and composable — without importing a framework to express it.
// Factory functions: zero bytes of framework, trivial to test
export function createOrderService({
payments = new Payments(),
inventory = new Inventory(),
shipping = new Shipping(),
} = {}) {
return {
async place(order) {
await payments.charge(order.total);
await inventory.reserve(order.items);
await shipping.schedule(order);
}
};
}
// Production: createOrderService()
// Tests: createOrderService({ payments: mockPayments, ... })
You get everything the decorator-heavy DI container gave you — substitutable dependencies, explicit composition, testability — and you ship zero bytes of framework to get it. Tree-shaking works. The wiring is code you can read.
The observer pattern: the memory leak factory
Observers are so natural in the browser — components listening to events, stores notifying subscribers — that it's easy to ship them without thinking about teardown. And every subscription without a matching unsubscription is a memory leak.
The shape of the leak:
class Dashboard {
constructor(bus) {
this.data = new Array(10_000); // nontrivial payload
bus.on('update', () => this.render()); // closure captures `this`
// `bus` now holds a reference that pins this Dashboard forever
}
}
When the dashboard is "removed" from the UI, the user thinks it's gone. The garbage collector disagrees — the event bus still holds the callback, the callback still closes over this, and this still holds the 10,000-entry array. Multiply by every screen the user has visited this session.
Two patterns make the leak impossible. First, always return an unsubscribe function from on():
on(event, cb) {
this.listeners.get(event)?.add(cb) ?? this.listeners.set(event, new Set([cb]));
return () => this.listeners.get(event)?.delete(cb);
}
Second, adopt AbortController as the lifecycle primitive for the component. Every subscription, every fetch, every event listener takes the same signal. When the component unmounts, you abort the controller once and everything it spawned is torn down:
class Dashboard {
constructor(bus) {
this.ac = new AbortController();
bus.on('update', () => this.render(), { signal: this.ac.signal });
fetch('/api/data', { signal: this.ac.signal }).then(/* ... */);
}
destroy() { this.ac.abort(); }
}
AbortController is one of the most useful primitives the edge has added in recent years. Treat it as the browser's answer to structured concurrency. Thread it through everything.
The repository pattern: abstraction without reason
The repository pattern's premise on the server is that data access varies — SQL, NoSQL, cache, search index — and the rest of your code shouldn't care. At the edge, the question is whether you actually have enough data-source variation to justify the abstraction layer. Many browser apps don't — they talk to one HTTP API and maybe a local cache. But some genuinely do: offline-first PWAs with IndexedDB persistence, background sync, and a remote API have three meaningfully different data paths, and a repository seam earns its keep there. The mistake is importing a Java-style repository hierarchy before you've earned it — recreating an ORM that nobody asked for, for a swap that will never happen.
There's a simpler shape that solves the actual problems — request deduplication, caching, rollback — without the abstraction tower:
class UserService {
#cache = new Map();
#inflight = new Map();
async get(id) {
if (this.#inflight.has(id)) return this.#inflight.get(id);
const cached = this.#cache.get(id);
if (cached && Date.now() - cached.ts < 60_000) return cached.data;
const p = fetch(`/api/users/${id}`).then(r => r.json())
.then(user => {
this.#cache.set(id, { data: user, ts: Date.now() });
this.#inflight.delete(id);
return user;
});
this.#inflight.set(id, p);
return p;
}
}
Fifty lines solve deduplication, freshness, and caching. If your app later grows an IndexedDB offline layer or a background sync queue, that's when you introduce the repository seam — because now the swap is real. Until then, inline clarity beats ceremonial structure.
The migration table
A reference card that compresses the pattern shifts worth knowing:
| Server pattern | Edge reality | What to do instead |
|---|---|---|
| Singleton bound to process lifecycle | Bound to tab lifecycle (hours, not minutes) | Bounded cache + TTL + explicit destroy() |
| Heavy DI container | Runs at every page load | Factory functions; tree-shake the framework away |
| Observer/pub-sub | Easy to leak on unmount | Always return unsubscribe; adopt AbortController |
| Repository pattern | Homogeneous data sources | Direct service, add abstraction when swap is real |
| Thread pool | Single thread | Web Workers for CPU-bound work only |
@Transactional |
No transactions over the network | Optimistic updates with rollback |
ThreadLocal |
Closure scope | Just use variables |
synchronized |
Not a concept | There's one thread |
| Connection pool | HTTP/2 multiplexing (6-per-origin limit on HTTP/1.1) | Domain sharding if H1; let the browser multiplex on H2 |
The underlying lesson
The patterns that fail at the edge fail because their assumptions about environment don't survive the trip. Process-bounded lifecycles become session-bounded lifecycles. Boot-time costs become page-load costs. Framework overhead becomes a download. Implicit cleanup becomes explicit cleanup, or else.
The patterns that thrive at the edge — caching with bounds and TTLs, request deduplication, optimistic updates with rollback, explicit lifecycle tokens, progressive degradation — are mostly the same patterns you'd find in any serious distributed systems engineer's toolkit. They weren't invented for the browser. They were invented for environments with unreliable networks, constrained resources, and long-lived state. Which is exactly where you are.