Overview
Shipped implements a sophisticated multi-layer caching system to minimize expensive external API calls to package registries. The cache architecture combines in-memory (L1) and persistent file-based (L2) storage with request coalescing to prevent duplicate concurrent requests.Architecture
Two-Layer Design
Cache Layers
| Layer | Backend | Speed | Persistence | Max Size | Eviction |
|---|---|---|---|---|---|
| L1 | Memory (Map) | ~0.1ms | No (lost on restart) | Configurable (default: 50MB / 2000 items) | LRU |
| L2 | File System | ~5-10ms | Yes (survives restart) | Unlimited | TTL-based pruning |
server/services/package/cache.ts:23-44
Implementation Details
BentoCache Backend
Shipped uses BentoCache for the underlying cache implementation:server/services/package/cache.ts:23-44
Coalescing Cache Wrapper
Shipped wraps BentoCache with a custom coalescing layer to prevent duplicate concurrent requests:server/libs/cache/coalescing-cache.ts:33-110
Cache Keys and Namespacing
Key Generation
Cache keys are derived from package hashes and include version information:- Provider isolation - GitHub and NPM caches don’t collide
- Version safety - Code changes invalidate old cache entries
- Config-based invalidation - Changing package config changes its hash
docs/architecture/package-system.md:286-299
Why Include Versions?
Including provider and implementation versions in the namespace prevents:- Schema changes - New provider version may return different data structure
- Bug fixes - Implementation changes should fetch fresh data
- Stale data issues - Old cached data won’t be used after upgrades
TTL Strategy
Default TTL
Package data is cached with a 3-hour TTL:server/config.ts:19-24
Why 3 Hours?
This balances:- Freshness - Most packages don’t release more than every few hours
- API limits - Reduces calls to external APIs (GitHub: 5000/hour, NPM: unlimited)
- User experience - Updates appear quickly without constant polling
Dynamic TTL with Policies
Providers can override TTL per response using cache policies:server/libs/cache/types.ts:17-20
Caching Null and Undefined
Why Cache Failures?
Shipped caches “not found” results to avoid hammering external APIs with requests for:- Typos in package names
- Deleted packages
- Non-existent versions
- Private packages (when unauthenticated)
Implementation
server/libs/cache/coalescing-cache.ts:86-97
Control via Options
You can prevent caching of nil values:server/libs/cache/types.ts:30
Request Coalescing
The Problem
Without coalescing, multiple concurrent requests for the same package trigger multiple API calls:The Solution
With coalescing, concurrent requests share a single API call:How It Works
Using Effect’sDeferred primitive:
- First request creates a
Deferredand stores it in a map - Concurrent requests find the existing
Deferredand await it - After fetch completes, the
Deferredis resolved with the result - All waiters receive the same result
- Deferred is removed from the map
server/libs/cache/coalescing-cache.ts:57-109
When Coalescing Matters
- Cold cache - After server restart, first requests hit external APIs
- Cache expiration - Multiple users accessing recently-expired entries
- New packages - First time a package is requested
- High concurrency - Many users viewing the same package simultaneously
Cache Statistics
The cache tracks three metrics:server/libs/cache/types.ts:45-49
Access stats programmatically:
Configuration
Environment Variables
| Variable | Type | Default | Description |
|---|---|---|---|
SERVER_PACKAGES_CACHE_DISABLED | boolean | false | Completely disable caching |
SERVER_PACKAGES_CACHE_DIR | string | "cache" | L2 cache directory |
SERVER_PACKAGES_CACHE_TTL | integer | 10800 | TTL in seconds (3 hours) |
SERVER_PACKAGES_CACHE_MAX_SIZE | string | "50mb" | L1 max size |
SERVER_PACKAGES_CACHE_MAX_ITEMS | integer | 2000 | L1 max items |
SERVER_PACKAGES_CACHE_PRUNE_INTERVAL | integer | 1200 | L2 pruning interval (20 min) |
server/config.ts:11-39
Disabling Cache
For development or debugging:server/services/package/cache.ts:14-18
Tuning L1 Size
Adjust based on your package count and available memory:maxItems × avgPackageSize × 2 (JS overhead)
Performance Characteristics
Latency Breakdown
| Scenario | L1 | L2 | Coalesced | External API |
|---|---|---|---|---|
| Latency | 0.1ms | 5-10ms | 50-500ms | 100-2000ms |
| API Call | No | No | No | Yes |
| Disk I/O | No | Yes | Maybe | Maybe |
Cache Hit Scenarios
Cold Start Behavior
On server restart:- L1 is empty - Memory is cleared
- L2 persists - File cache survives
- First requests populate L1 from L2 (fast)
- L2 misses trigger external API calls (slower)
- First few requests: 5-10ms (L2 hits)
- New packages: 100-2000ms (API calls)
- After warmup: less than 0.1ms (L1 hits)
Monitoring and Debugging
Check Cache Directory
Inspect Cache Stats
Add logging to track cache performance:Clear Cache
To force fresh fetches:Best Practices
Sizing L1 Cache
- Calculate package count: Number of packages in your
lists.yaml - Estimate size: Average package is ~10KB
- Add 50% headroom: Account for metadata overhead
- Set limits:
TTL Recommendations
| Package Type | Recommended TTL | Reason |
|---|---|---|
| Stable libraries | 6 hours | Infrequent releases |
| Active development | 1 hour | Frequent releases |
| Pre-releases | 30 minutes | Rapid iteration |
| Not found | 10 minutes | May be temporarily unavailable |
Production Optimization
-
Pre-warm cache on startup:
-
Monitor hit ratio:
-
Persist cache directory:
Troubleshooting
High Memory Usage
If memory usage is high:-
Reduce L1 size:
-
Check for cache leaks:
Stale Data
If packages show outdated data:- Check TTL - May be too long
- Clear cache -
rm -rf cache/* - Verify provider version - Code changes update namespace
Slow Performance
If requests are slow despite caching:- Check cache stats - Low hit ratio indicates problem
- Verify L2 disk speed - Slow disks hurt L2 performance
- Increase L1 size - More items in memory = more hits
Summary
Shipped’s caching system provides:- Two-layer architecture - Fast L1 memory + persistent L2 file cache
- Request coalescing - Prevents duplicate concurrent API calls
- Smart TTL - 3-hour default with policy-based overrides
- Nil caching - Avoids hammering APIs for non-existent packages
- Version-safe keys - Code changes invalidate old entries
- 95%+ hit ratio - Minimal external API calls in production