Caching Strategies
dcupl provides built-in caching mechanisms and strategies to optimize performance. Learn how to leverage automatic query caching, manage cache invalidation, and implement custom caching patterns including IndexedDB, localStorage, and offline-first approaches.
Understanding dcupl Caching
flowchart LR
Q[Query] --> C{Cache Hit?}
C -->|Yes| R[Return Cached]
C -->|No| E[Execute Query]
E --> S[Store in Cache]
S --> R2[Return Results]
U[Data Update] --> I[Invalidate Cache]
dcupl automatically caches query results at the list level. When you execute a query, results are cached until:
- The query changes
- Data is updated
- Manual cache invalidation
Key concepts:
- Automatic caching - Query results cached by default
- Cache invalidation - Triggered by data updates
- Manual control - Override automatic behavior
- List-level caching - Each list maintains its own cache
Automatic Query Caching
How It Works
const productList = dcupl.lists.create({ modelKey: 'Product' });
// First execution - computes results
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const results1 = productList.catalog.query.items(); // Query executed
// Second execution - returns cached results
const results2 = productList.catalog.query.items(); // From cache (instant)
// Same reference!
console.log(results1 === results2); // truePerformance: Cached queries return instantly (< 1ms).
Cache Scope
Each list maintains its own query cache:
const list1 = dcupl.lists.create({ modelKey: 'Product' });
const list2 = dcupl.lists.create({ modelKey: 'Product' });
list1.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
list2.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Clothing',
});
// Each list has independent cache
const electronics = list1.catalog.query.items(); // Cached for list1
const clothing = list2.catalog.query.items(); // Cached for list2Cache Invalidation
Automatic Invalidation
Cache is automatically invalidated when data changes:
const productList = dcupl.lists.create({ modelKey: 'Product' });
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const results1 = productList.catalog.query.items(); // Cached
// Update data - cache invalidated
dcupl.data.update([{ key: 'p1', category: 'Clothing' }], { model: 'Product' });
const results2 = productList.catalog.query.items(); // Re-computed
console.log(results1 === results2); // falseManual Cache Invalidation
Force cache refresh:
const productList = dcupl.lists.create({ modelKey: 'Product' });
// Execute query
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const results1 = productList.catalog.query.items(); // Cached
// Manually invalidate
productList.catalog.query.clear();
// Re-apply query
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const results2 = productList.catalog.query.items(); // Re-computedPartial Cache Invalidation
With partial updates, only affected queries are invalidated:
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const results1 = productList.catalog.query.items(); // Cached
// Update unrelated property - cache still valid
dcupl.data.update([{ key: 'p1', stock: 100 }], { model: 'Product' }); // Not queried on
const results2 = productList.catalog.query.items(); // Still cached!
console.log(results1 === results2); // trueList Update and Cache
list.update()
The list.update() method triggers cache refresh:
const productList = dcupl.lists.create({ modelKey: 'Product' });
productList.catalog.query.addCondition({
attribute: 'inStock',
operator: 'eq',
value: true,
});
const results1 = productList.catalog.query.items();
// Update data and refresh list
dcupl.data.update([{ key: 'p1', inStock: false }], { model: 'Product' });
// Trigger list update
await productList.update();
const results2 = productList.catalog.query.items(); // Fresh results
console.log(results1.length !== results2.length); // trueautoUpdate Option
Enable automatic list updates:
const productList = dcupl.lists.create({
modelKey: 'Product',
autoUpdate: true, // Auto-refresh on data changes
});
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: 'Electronics',
});
const results1 = productList.catalog.query.items();
// Update data
dcupl.data.update([{ key: 'p1', category: 'Clothing' }], { model: 'Product' });
// List automatically updated!
const results2 = productList.catalog.query.items(); // Fresh dataNote: autoUpdate: true can impact performance for frequent updates.
Facet Caching
Facets are also cached automatically:
const productList = dcupl.lists.create({ modelKey: 'Product' });
// First call - computed
const facets1 = productList.catalog.fn.facets({ attribute: 'category' });
// Second call - cached
const facets2 = productList.catalog.fn.facets({ attribute: 'category' });
console.log(facets1 === facets2); // trueFacet Cache Invalidation
const productList = dcupl.lists.create({ modelKey: 'Product' });
const facets1 = productList.catalog.fn.facets({ attribute: 'category' });
// Update data - facet cache invalidated
dcupl.data.update([{ key: 'p1', category: 'NewCategory' }], { model: 'Product' });
const facets2 = productList.catalog.fn.facets({ attribute: 'category' });
console.log(facets1 === facets2); // false (re-computed)Aggregation Caching
Aggregations are cached like queries:
const orderList = dcupl.lists.create({ modelKey: 'Order' });
orderList.catalog.query.addCondition({
attribute: 'status',
operator: 'eq',
value: 'completed',
});
// First call - computed
const stats1 = orderList.catalog.fn.aggregate({
attribute: 'total',
types: ['sum', 'avg', 'count'],
});
// Second call - cached
const stats2 = orderList.catalog.fn.aggregate({
attribute: 'total',
types: ['sum', 'avg', 'count'],
});
console.log(stats1 === stats2); // trueIndexedDB Integration
IndexedDB provides persistent storage for large datasets with asynchronous access. Use it for caching dcupl data across browser sessions.
Basic IndexedDB Cache
class IndexedDBCache {
private dbName = 'dcupl-cache';
private storeName = 'data';
private db: IDBDatabase | null = null;
async open(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'key' });
}
};
});
}
async set<T>(key: string, data: T, ttl?: number): Promise<void> {
if (!this.db) await this.open();
const entry = {
key,
data,
timestamp: Date.now(),
expiry: ttl ? Date.now() + ttl : null,
};
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
const request = store.put(entry);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async get<T>(key: string): Promise<T | null> {
if (!this.db) await this.open();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const request = store.get(key);
request.onsuccess = () => {
const entry = request.result;
if (!entry) {
resolve(null);
return;
}
// Check expiry
if (entry.expiry && Date.now() > entry.expiry) {
this.delete(key);
resolve(null);
return;
}
resolve(entry.data as T);
};
request.onerror = () => reject(request.error);
});
}
async delete(key: string): Promise<void> {
if (!this.db) await this.open();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async clear(): Promise<void> {
if (!this.db) await this.open();
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}Using IndexedDB with dcupl
const idbCache = new IndexedDBCache();
async function initDcuplWithCache() {
const dcupl = new Dcupl();
// Define model
dcupl.models.set({
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'category', type: 'string' },
{ key: 'price', type: 'float' },
],
});
// Try to load from IndexedDB first
const cachedProducts = await idbCache.get<Product[]>('products');
if (cachedProducts) {
console.log('Loading from IndexedDB cache');
dcupl.data.set(cachedProducts, { model: 'Product' });
} else {
console.log('Fetching fresh data');
const products = await fetch('/api/products').then((r) => r.json());
dcupl.data.set(products, { model: 'Product' });
// Cache for 1 hour
await idbCache.set('products', products, 60 * 60 * 1000);
}
await dcupl.init();
return dcupl;
}IndexedDB with Versioning
class VersionedIndexedDBCache {
private cache: IndexedDBCache;
private versionKey = 'cache-version';
constructor() {
this.cache = new IndexedDBCache();
}
async getData<T>(key: string, currentVersion: string): Promise<T | null> {
const versionInfo = await this.cache.get<{ version: string }>(this.versionKey);
// Version mismatch - invalidate cache
if (!versionInfo || versionInfo.version !== currentVersion) {
await this.cache.clear();
await this.cache.set(this.versionKey, { version: currentVersion });
return null;
}
return this.cache.get(key);
}
async setData<T>(key: string, data: T, version: string, ttl?: number): Promise<void> {
await this.cache.set(this.versionKey, { version });
await this.cache.set(key, data, ttl);
}
}
// Usage with dcupl
const versionedCache = new VersionedIndexedDBCache();
const DATA_VERSION = '1.2.0'; // Bump when data schema changes
async function loadProducts() {
const cached = await versionedCache.getData('products', DATA_VERSION);
if (cached) {
return cached;
}
const products = await fetch('/api/products').then((r) => r.json());
await versionedCache.setData('products', products, DATA_VERSION, 3600000);
return products;
}localStorage Strategies
localStorage provides simple synchronous storage for smaller datasets (< 5MB).
Basic localStorage Cache
class LocalStorageCache {
private prefix = 'dcupl:';
set<T>(key: string, data: T, ttl?: number): void {
const entry = {
data,
timestamp: Date.now(),
expiry: ttl ? Date.now() + ttl : null,
};
try {
localStorage.setItem(this.prefix + key, JSON.stringify(entry));
} catch (e) {
// Handle quota exceeded
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
this.clearExpired();
localStorage.setItem(this.prefix + key, JSON.stringify(entry));
}
}
}
get<T>(key: string): T | null {
const raw = localStorage.getItem(this.prefix + key);
if (!raw) return null;
try {
const entry = JSON.parse(raw);
// Check expiry
if (entry.expiry && Date.now() > entry.expiry) {
this.delete(key);
return null;
}
return entry.data as T;
} catch {
return null;
}
}
delete(key: string): void {
localStorage.removeItem(this.prefix + key);
}
clearExpired(): void {
const now = Date.now();
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(this.prefix)) continue;
try {
const entry = JSON.parse(localStorage.getItem(key) || '');
if (entry.expiry && now > entry.expiry) {
keysToRemove.push(key);
}
} catch {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
}
clear(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(this.prefix)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
}
}localStorage for User Preferences
const lsCache = new LocalStorageCache();
// Cache user query preferences
function saveQueryPreferences(userId: string, preferences: QueryPreferences) {
lsCache.set(`prefs:${userId}`, preferences);
}
function loadQueryPreferences(userId: string): QueryPreferences | null {
return lsCache.get<QueryPreferences>(`prefs:${userId}`);
}
// Usage
const prefs = loadQueryPreferences('user-123') || {
sortBy: 'name',
sortOrder: 'asc',
pageSize: 20,
filters: {},
};
const productList = dcupl.lists.create({
modelKey: 'Product',
pagination: { pageSize: prefs.pageSize },
});
if (prefs.filters.category) {
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: prefs.filters.category,
});
}When to Use localStorage vs IndexedDB
| Feature | localStorage | IndexedDB |
|---|---|---|
| Storage Limit | ~5MB | ~50MB+ (varies) |
| API | Synchronous | Asynchronous |
| Data Types | Strings only | Any structured data |
| Best For | Small configs | Large datasets |
| Query Support | None | Indexes available |
| Transactions | No | Yes |
// localStorage: User preferences, small configs
localStorage.setItem('theme', 'dark');
localStorage.setItem('language', 'en');
// IndexedDB: Product catalogs, large datasets
await idbCache.set('products', largeProductArray);
await idbCache.set('categories', categoryTree);Cache Invalidation Patterns
Time-Based Invalidation (TTL)
class TTLCache<T> {
private cache: Map<string, { data: T; expiry: number }> = new Map();
set(key: string, data: T, ttlMs: number): void {
this.cache.set(key, {
data,
expiry: Date.now() + ttlMs,
});
}
get(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiry) {
this.cache.delete(key);
return null;
}
return entry.data;
}
invalidate(key: string): void {
this.cache.delete(key);
}
invalidatePattern(pattern: RegExp): void {
for (const key of this.cache.keys()) {
if (pattern.test(key)) {
this.cache.delete(key);
}
}
}
}
// Usage
const queryCache = new TTLCache<Product[]>();
function getCachedProducts(category: string): Product[] | null {
const cacheKey = `products:${category}`;
return queryCache.get(cacheKey);
}
function cacheProducts(category: string, products: Product[]): void {
const cacheKey = `products:${category}`;
queryCache.set(cacheKey, products, 5 * 60 * 1000); // 5 minutes
}
// Invalidate all product caches when data updates
dcupl.on('dataChange', (event) => {
if (event.model === 'Product') {
queryCache.invalidatePattern(/^products:/);
}
});Event-Based Invalidation
class EventDrivenCache {
private cache: Map<string, unknown> = new Map();
private dependencies: Map<string, Set<string>> = new Map();
set<T>(key: string, data: T, dependsOn: string[] = []): void {
this.cache.set(key, data);
// Track dependencies
dependsOn.forEach((dep) => {
if (!this.dependencies.has(dep)) {
this.dependencies.set(dep, new Set());
}
this.dependencies.get(dep)!.add(key);
});
}
get<T>(key: string): T | null {
return (this.cache.get(key) as T) || null;
}
invalidate(dependency: string): void {
const affectedKeys = this.dependencies.get(dependency);
if (!affectedKeys) return;
affectedKeys.forEach((key) => {
this.cache.delete(key);
});
this.dependencies.delete(dependency);
}
}
// Usage with dcupl
const eventCache = new EventDrivenCache();
// Cache query results with model dependency
function cacheQueryResult<T>(queryId: string, results: T[], model: string) {
eventCache.set(queryId, results, [model]);
}
// Invalidate when model data changes
dcupl.on('dataChange', (event) => {
eventCache.invalidate(event.model);
});Version-Based Invalidation
class VersionedCache {
private cache: Map<string, { data: unknown; version: number }> = new Map();
private versions: Map<string, number> = new Map();
getVersion(namespace: string): number {
return this.versions.get(namespace) || 0;
}
bumpVersion(namespace: string): number {
const current = this.getVersion(namespace);
const newVersion = current + 1;
this.versions.set(namespace, newVersion);
return newVersion;
}
set<T>(key: string, data: T, namespace: string): void {
this.cache.set(key, {
data,
version: this.getVersion(namespace),
});
}
get<T>(key: string, namespace: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
// Check if version is current
if (entry.version !== this.getVersion(namespace)) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
}
// Usage
const versionCache = new VersionedCache();
// Cache products
versionCache.set('all-products', products, 'Product');
// On data update, bump version (invalidates all Product caches)
dcupl.on('dataChange', (event) => {
versionCache.bumpVersion(event.model);
});
// Get returns null if version changed
const cached = versionCache.get('all-products', 'Product');Stale-While-Revalidate Pattern
Return cached data immediately while fetching fresh data in the background.
sequenceDiagram participant U as User participant C as Cache participant A as API U->>C: Request data C->>U: Return stale data (instant) C->>A: Fetch fresh data (background) A->>C: Fresh data C->>C: Update cache Note over C: Next request gets fresh data
Implementation
class SWRCache<T> {
private cache: Map<string, { data: T; timestamp: number }> = new Map();
private pending: Map<string, Promise<T>> = new Map();
private ttl: number;
constructor(ttlMs: number = 60000) {
this.ttl = ttlMs;
}
async get(
key: string,
fetcher: () => Promise<T>,
options?: { forceRefresh?: boolean }
): Promise<T> {
const cached = this.cache.get(key);
const now = Date.now();
// Fresh cache - return immediately
if (cached && !options?.forceRefresh) {
const isStale = now - cached.timestamp > this.ttl;
if (!isStale) {
return cached.data;
}
// Stale - return cached, revalidate in background
this.revalidate(key, fetcher);
return cached.data;
}
// No cache - fetch and wait
return this.fetchAndCache(key, fetcher);
}
private async fetchAndCache(key: string, fetcher: () => Promise<T>): Promise<T> {
// Deduplicate concurrent requests
const pending = this.pending.get(key);
if (pending) return pending;
const promise = fetcher().then((data) => {
this.cache.set(key, { data, timestamp: Date.now() });
this.pending.delete(key);
return data;
});
this.pending.set(key, promise);
return promise;
}
private async revalidate(key: string, fetcher: () => Promise<T>): Promise<void> {
// Skip if already revalidating
if (this.pending.has(key)) return;
try {
await this.fetchAndCache(key, fetcher);
} catch (error) {
console.error('Revalidation failed:', error);
// Keep stale data on failure
}
}
invalidate(key: string): void {
this.cache.delete(key);
}
}Using SWR with dcupl
const swrCache = new SWRCache<Product[]>(5 * 60 * 1000); // 5 min TTL
class ProductCatalog {
private dcupl: Dcupl;
private list: DcuplList;
constructor() {
this.dcupl = new Dcupl();
this.setupModel();
}
private setupModel() {
this.dcupl.models.set({
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'category', type: 'string' },
{ key: 'price', type: 'float' },
],
});
}
async getProducts(category: string): Promise<Product[]> {
const cacheKey = `products:${category}`;
return swrCache.get(cacheKey, async () => {
// Fetch fresh data
const products = await fetch(`/api/products?category=${category}`).then((r) => r.json());
// Update dcupl
this.dcupl.data.set(products, { model: 'Product' });
await this.dcupl.init();
// Create/update list
if (!this.list) {
this.list = this.dcupl.lists.create({ modelKey: 'Product' });
}
this.list.catalog.query.clear();
this.list.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: category,
});
return this.list.catalog.query.items();
});
}
invalidateCategory(category: string): void {
swrCache.invalidate(`products:${category}`);
}
}
// Usage
const catalog = new ProductCatalog();
// First call - fetches from API
const electronics = await catalog.getProducts('Electronics');
// Subsequent calls within TTL - instant from cache
const electronicsAgain = await catalog.getProducts('Electronics');
// After TTL - returns stale, refreshes in background
const electronicsLater = await catalog.getProducts('Electronics');Offline-First Patterns
Build applications that work offline and sync when connectivity returns.
flowchart TB
subgraph Online
A[Fetch API] --> B[Update Cache]
B --> C[Update dcupl]
end
subgraph Offline
D[Read Cache] --> E[Load dcupl]
E --> F[Queue Changes]
end
G{Online?}
G -->|Yes| Online
G -->|No| Offline
F -->|Reconnect| H[Sync Queue]
H --> A
Offline-First Service
class OfflineFirstService {
private dcupl: Dcupl;
private idbCache: IndexedDBCache;
private syncQueue: Array<{ action: string; data: unknown }> = [];
private isOnline: boolean = navigator.onLine;
constructor() {
this.dcupl = new Dcupl();
this.idbCache = new IndexedDBCache();
this.setupNetworkListeners();
}
private setupNetworkListeners() {
window.addEventListener('online', () => {
this.isOnline = true;
this.syncPendingChanges();
});
window.addEventListener('offline', () => {
this.isOnline = false;
});
}
async initialize(): Promise<void> {
// Setup model
this.dcupl.models.set({
key: 'Product',
properties: [
{ key: 'name', type: 'string' },
{ key: 'category', type: 'string' },
{ key: 'price', type: 'float' },
{ key: 'updatedAt', type: 'date' },
],
});
// Load from cache first (instant)
const cachedData = await this.idbCache.get<Product[]>('products');
if (cachedData) {
this.dcupl.data.set(cachedData, { model: 'Product' });
await this.dcupl.init();
}
// Load sync queue
const queue = await this.idbCache.get<Array<{ action: string; data: unknown }>>('sync-queue');
if (queue) {
this.syncQueue = queue;
}
// Fetch fresh data if online
if (this.isOnline) {
await this.refreshFromServer();
}
}
private async refreshFromServer(): Promise<void> {
try {
const products = await fetch('/api/products').then((r) => r.json());
// Update cache
await this.idbCache.set('products', products);
// Update dcupl
this.dcupl.data.set(products, { model: 'Product' });
await this.dcupl.init();
} catch (error) {
console.warn('Failed to refresh from server:', error);
}
}
async updateProduct(key: string, changes: Partial<Product>): Promise<void> {
// Update local dcupl immediately
this.dcupl.data.update(
[{ key, ...changes, updatedAt: new Date().toISOString() }],
{ model: 'Product' }
);
// Update local cache
const products = await this.idbCache.get<Product[]>('products') || [];
const index = products.findIndex((p) => p.key === key);
if (index >= 0) {
products[index] = { ...products[index], ...changes };
await this.idbCache.set('products', products);
}
if (this.isOnline) {
// Sync immediately
await this.syncToServer({ action: 'update', data: { key, ...changes } });
} else {
// Queue for later sync
this.queueChange({ action: 'update', data: { key, ...changes } });
}
}
private queueChange(change: { action: string; data: unknown }): void {
this.syncQueue.push(change);
this.idbCache.set('sync-queue', this.syncQueue);
}
private async syncPendingChanges(): Promise<void> {
if (this.syncQueue.length === 0) return;
const queue = [...this.syncQueue];
this.syncQueue = [];
for (const change of queue) {
try {
await this.syncToServer(change);
} catch (error) {
// Re-queue failed changes
this.queueChange(change);
}
}
await this.idbCache.set('sync-queue', this.syncQueue);
// Refresh from server to get any remote changes
await this.refreshFromServer();
}
private async syncToServer(change: { action: string; data: unknown }): Promise<void> {
await fetch('/api/products/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change),
});
}
getList(): DcuplList {
return this.dcupl.lists.create({ modelKey: 'Product' });
}
}Service Worker Integration
// In service worker (sw.js)
const CACHE_NAME = 'dcupl-data-v1';
const DATA_URLS = ['/api/products', '/api/categories'];
self.addEventListener('fetch', (event: FetchEvent) => {
const url = new URL(event.request.url);
if (DATA_URLS.some((path) => url.pathname.startsWith(path))) {
event.respondWith(staleWhileRevalidate(event.request));
}
});
async function staleWhileRevalidate(request: Request): Promise<Response> {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => cachedResponse!);
return cachedResponse || fetchPromise;
}
// In main app
async function loadWithServiceWorker() {
// This will use service worker cache automatically
const products = await fetch('/api/products').then((r) => r.json());
dcupl.data.set(products, { model: 'Product' });
await dcupl.init();
}Cache Warming Strategies
Pre-populate caches to improve initial load performance.
Eager Cache Warming
class CacheWarmer {
private dcupl: Dcupl;
private idbCache: IndexedDBCache;
constructor(dcupl: Dcupl) {
this.dcupl = dcupl;
this.idbCache = new IndexedDBCache();
}
async warmOnIdle(): Promise<void> {
// Use requestIdleCallback for non-blocking warming
if ('requestIdleCallback' in window) {
requestIdleCallback(() => this.warmCaches(), { timeout: 5000 });
} else {
// Fallback with setTimeout
setTimeout(() => this.warmCaches(), 1000);
}
}
private async warmCaches(): Promise<void> {
const commonCategories = ['Electronics', 'Clothing', 'Books', 'Home'];
const productList = this.dcupl.lists.create({ modelKey: 'Product' });
for (const category of commonCategories) {
productList.catalog.query.clear();
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: category,
});
// Execute query to warm dcupl's internal cache
const results = productList.catalog.query.items();
// Also warm persistent cache
await this.idbCache.set(`category:${category}`, results, 3600000);
}
// Warm facets
const facets = productList.catalog.fn.facets({ attribute: 'category' });
await this.idbCache.set('facets:category', facets, 3600000);
productList.destroy();
}
}
// Usage
const dcupl = new Dcupl();
await dcupl.init();
const warmer = new CacheWarmer(dcupl);
warmer.warmOnIdle();Predictive Cache Warming
class PredictiveCacheWarmer {
private accessHistory: Map<string, number> = new Map();
private dcupl: Dcupl;
constructor(dcupl: Dcupl) {
this.dcupl = dcupl;
this.loadHistory();
}
private loadHistory(): void {
const stored = localStorage.getItem('access-history');
if (stored) {
const entries = JSON.parse(stored);
this.accessHistory = new Map(entries);
}
}
private saveHistory(): void {
const entries = Array.from(this.accessHistory.entries());
localStorage.setItem('access-history', JSON.stringify(entries));
}
trackAccess(category: string): void {
const count = this.accessHistory.get(category) || 0;
this.accessHistory.set(category, count + 1);
this.saveHistory();
}
getMostAccessed(limit: number = 5): string[] {
return Array.from(this.accessHistory.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([category]) => category);
}
async warmPredictedCategories(): Promise<void> {
const topCategories = this.getMostAccessed(5);
const productList = this.dcupl.lists.create({ modelKey: 'Product' });
for (const category of topCategories) {
productList.catalog.query.clear();
productList.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: category,
});
// Warm cache
productList.catalog.query.items();
}
productList.destroy();
}
}
// Usage
const predictor = new PredictiveCacheWarmer(dcupl);
// Track when user accesses a category
function onCategorySelect(category: string) {
predictor.trackAccess(category);
// ... load products
}
// Warm predicted caches on app start
predictor.warmPredictedCategories();Background Data Refresh
class BackgroundRefresher {
private dcupl: Dcupl;
private refreshInterval: number;
private intervalId: number | null = null;
constructor(dcupl: Dcupl, refreshIntervalMs: number = 5 * 60 * 1000) {
this.dcupl = dcupl;
this.refreshInterval = refreshIntervalMs;
}
start(): void {
// Initial refresh
this.refresh();
// Schedule periodic refreshes
this.intervalId = window.setInterval(() => {
this.refresh();
}, this.refreshInterval);
// Also refresh on visibility change
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.refresh();
}
});
}
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private async refresh(): Promise<void> {
// Don't refresh if offline
if (!navigator.onLine) return;
try {
const products = await fetch('/api/products?_t=' + Date.now()).then((r) => r.json());
this.dcupl.data.update(products, { model: 'Product' });
} catch (error) {
console.warn('Background refresh failed:', error);
}
}
}
// Usage
const refresher = new BackgroundRefresher(dcupl, 5 * 60 * 1000);
refresher.start();Multi-Level Caching
flowchart TB
Q[Query] --> L1{Memory Cache}
L1 -->|Hit| R[Return]
L1 -->|Miss| L2{Session Storage}
L2 -->|Hit| P1[Promote to L1]
P1 --> R
L2 -->|Miss| L3{IndexedDB}
L3 -->|Hit| P2[Promote to L1]
P2 --> R
L3 -->|Miss| F[Fetch Data]
F --> S[Store in All Levels]
S --> R
Implement multi-level caching strategy:
class MultiLevelCache {
private memoryCache: Map<string, unknown> = new Map();
private idbCache: IndexedDBCache;
constructor() {
this.idbCache = new IndexedDBCache();
}
async get<T>(key: string): Promise<T | null> {
// Level 1: Memory (fastest)
if (this.memoryCache.has(key)) {
return this.memoryCache.get(key);
}
// Level 2: Session storage
const sessionData = sessionStorage.getItem(key);
if (sessionData) {
const parsed = JSON.parse(sessionData);
this.memoryCache.set(key, parsed); // Promote to L1
return parsed;
}
// Level 3: IndexedDB (persistent)
const idbData = await this.idbCache.get<T>(key);
if (idbData) {
this.memoryCache.set(key, idbData); // Promote to L1
return idbData;
}
return null;
}
async set<T>(key: string, value: T, options?: { persistent?: boolean }): Promise<void> {
// Always store in memory
this.memoryCache.set(key, value);
// Store in session (survives refresh)
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
// Session storage full, skip
}
// Optionally persist to IndexedDB
if (options?.persistent) {
await this.idbCache.set(key, value);
}
}
async clear(key?: string): Promise<void> {
if (key) {
this.memoryCache.delete(key);
sessionStorage.removeItem(key);
await this.idbCache.delete(key);
} else {
this.memoryCache.clear();
sessionStorage.clear();
await this.idbCache.clear();
}
}
}
// Usage
const mlCache = new MultiLevelCache();
async function loadProductsWithMultiLevelCache() {
let products = await mlCache.get<Product[]>('products');
if (!products) {
products = await fetch('/api/products').then((r) => r.json());
await mlCache.set('products', products, { persistent: true });
}
dcupl.data.set(products, { model: 'Product' });
await dcupl.init();
}Best Practices
1. Leverage Automatic Caching
// Good: Reuse lists
const list = dcupl.lists.create({ modelKey: 'Product' });
function getProducts(category: string) {
list.catalog.query.clear();
list.catalog.query.addCondition({
attribute: 'category',
operator: 'eq',
value: category,
});
return list.catalog.query.items(); // Cached
}
// Bad: Create new lists
function getProducts(category: string) {
const list = dcupl.lists.create({ modelKey: 'Product' });
// ... query
// No caching benefit
}2. Use autoUpdate Sparingly
// Good: Enable only for real-time data
const realtimeList = dcupl.lists.create({
modelKey: 'Metric',
autoUpdate: true,
});
// Bad: Enable everywhere
const staticList = dcupl.lists.create({
modelKey: 'Category',
autoUpdate: true, // Unnecessary overhead
});3. Implement TTL for API Caching
// Good: Use TTL
class APICache {
private ttl = 300000; // 5 minutes
// ... implementation
}
// Bad: Cache forever
const data = localStorage.getItem('products');
// Never refreshed!4. Clear Cache When Needed
// Good: Clear on logout
function logout() {
dcupl.lists.getAll().forEach((list) => list.destroy());
dcupl.data.clear();
localStorage.clear();
await idbCache.clear();
}
// Bad: Never clear cache
function logout() {
// Stale data remains
}5. Monitor Cache Size
// Good: Monitor and limit cache
class Cache<T> {
private maxSize = 100;
set(key: string, value: T) {
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
}Troubleshooting
Stale Data
Problem: Cache returns outdated data
Solution: Invalidate cache after updates:
dcupl.data.update([{ key: 'p1', price: 999 }], { model: 'Product' });
// Force refresh
await list.update();
// Also invalidate external caches
await idbCache.delete('products');
swrCache.invalidate('products:all');Cache Growing Too Large
Problem: Memory usage increases
Solution: Implement cache size limits:
class LRUCache<T> {
private maxSize = 100;
private cache = new Map<string, T>();
set(key: string, value: T) {
// Delete oldest if at capacity
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
}IndexedDB Quota Exceeded
Problem: IndexedDB storage full
Solution: Implement cleanup and compression:
async function cleanupOldData() {
const cache = new IndexedDBCache();
await cache.open();
// Remove entries older than 7 days
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
// Iterate and delete old entries
}Inconsistent Results
Problem: Different lists show different data
Solution: Ensure single source of truth:
// Good: Single dcupl instance
const dcupl = new Dcupl();
const list1 = dcupl.lists.create({ modelKey: 'Product' });
const list2 = dcupl.lists.create({ modelKey: 'Product' });
// Bad: Multiple instances
const dcupl1 = new Dcupl();
const dcupl2 = new Dcupl();What's Next?
- Performance - Performance optimization
- Data Management - Loading and updating data
- Lists - Working with lists
- Queries - Query optimization