When building applications on Cloudflare Workers with D1 databases, you'll eventually hit scaling challenges as your data grows. D1 has a hard cap of 10GB per database, which means horizontal scaling is inevitable for growing applications. This post outlines an architecture for implementing horizontal sharding with D1 that scales efficiently while maintaining simplicity.
The Challenge
Cloudflare D1 is a serverless SQL database that works beautifully with Workers, but like any database, it has limits. Most critically, D1 has a hard cap of 10GB per database, which means you'll inevitably need to shard as your data grows. As your application scales, you'll need strategies to:
- Scale beyond the 10GB capacity limit of a single D1 database instance
- Manage data locality for multi-region deployments
- Handle tenant isolation for multi-tenant applications
- Organize data temporally for time-series workloads
- Maintain performance as request volumes increase
The Solution: Universal ID-Based Sharding
Our approach centers around two core components:
- Universal ID Generator: Creates IDs that embed metadata about shard location
- Database Router: Directs queries to the appropriate shard based on the ID
Universal ID Structure
The IDs follow this format:
<timestamp(10)><shardHash(10)><typeHash(4)><random(8)>
Where:
- timestamp: Base-28 encoded timestamp (when the record was created)
- shardHash: Hashed representation of the shard identifier
- typeHash: Hashed representation of the record type
- random: Random characters for uniqueness
This design embeds routing information directly in the ID, enabling any service to determine which shard contains a record without consulting a central directory.
Database Binding Convention
For this architecture to work, we use a structured naming convention for database bindings:
DB_2025_03_04_T_m94ykqzkx6: D1Database;
For example: DB_2025_04_14_T_abcdefghij
This convention encodes:
- Creation date of the shard (for chronological organization)
- Tenant identifier (for multi-tenant isolation)
Key Components
1. Universal ID Generator
The ID generator handles:
- Generating new IDs with embedded metadata
- Decoding existing IDs to extract shard information
- Caching mappings between component values and their hashes
- Managing cache size to prevent memory issues
// Simplified interface
interface IUniversalIdGenerator {
generate(metadata: {
timestamp?: number;
recordType: string;
shardId: string;
}): Promise<string>;
decode(id: string): Promise<{
timestamp: number;
shardId: string;
recordType: string;
random: string;
}>;
}
2. Database Router
The router manages:
- Parsing database bindings from environment variables
- Mapping between shard IDs and database connections
- Routing queries to the appropriate shard
- Executing cross-shard operations when necessary
// Simplified interface
interface IDatabaseRouter {
getShardConnection(shardId: string): Promise<Database>;
getConnectionForId(id: string): Promise<Database>;
getLatestShard(): ShardBinding;
queryAll<T>(queryFn: (db: Database) => Promise<T[]>): Promise<T[]>;
queryById<T>(id: string, queryFn: (db: Database) => Promise<T[]>): Promise<T[]>;
queryByIds<T>(ids: string[], queryFn: (db: Database, ids: string[]) => Promise<T[]>): Promise<T[]>;
}
How It Works in Practice
Initialization
- During service startup, the router scans environment variables for database bindings
- It parses binding names to extract metadata (date, tenant ID)
- The router builds a mapping between shard IDs and database connections
- It identifies the "latest" shard for new records based on date information
Writing Data
- When creating a new record, the service uses the latest shard by default
- The ID generator creates an ID with the shard information embedded
- The record is written to the appropriate shard
- The ID contains all information needed to locate the record later
Reading Data
- When querying by ID, the router decodes the ID to determine the shard
- The query is routed directly to the correct shard
- For cross-shard queries, the router executes parallel operations
- Results are combined and returned to the caller
Benefits of This Approach
- No Central Coordination: Each service knows where to find data based on ID structure
- Horizontal Scaling: Add new shards without changing application code
- Tenant Isolation: Each tenant can have dedicated Worker instances and shards
- Efficient Routing: Direct access to the right shard without scanning multiple databases
- Time-Based Organization: Shards can be organized by date ranges
- Graceful Evolution: Newer shards can use different schemas than older ones
- Multi-Tenant Flexibility: While the architecture supports multiple tenants per Worker, it's designed to work well with a worker-per-tenant approach for true isolation
Implementation Considerations
Hashing Strategy
The ID generator uses SHA-256 hashing for component values, but only takes the first few bytes to keep IDs reasonably sized. This provides:
- Deterministic mapping (same input always yields same output)
- Collision resistance (different inputs are unlikely to produce the same hash)
- Uniform distribution (even spreading of values)
Cache Management
To prevent memory issues, the ID generator maintains a cache of limited size:
- Frequently used mappings are cached for performance
- Oldest entries are evicted when the cache reaches capacity
- Cache statistics are available for monitoring
Error Handling
Robust error handling is essential:
- Missing shard connections require clear error messages
- ID decoding failures should provide actionable information
- Cross-shard query failures need appropriate fallback strategies
Worker-per-Tenant Approach
While the Universal ID system technically supports multiple tenants sharing Workers and databases, there are significant advantages to deploying one Worker per tenant:
- Complete Isolation: No risk of one tenant's operations affecting another
- Independent Scaling: Each tenant can scale according to their own needs
- Simpler Deployment: Updates can be rolled out to tenants independently
- Enhanced Security: Stronger boundaries between tenant data
- Specialized Customization: Each tenant's Worker can be customized if needed
The Universal ID and Database Router architecture works perfectly with this approach, as each Worker only needs to know about its own tenant's shards.
Practical Migration Strategy
When implementing this architecture, consider these steps:
- Start with a single shard for simplicity
- Add sharding infrastructure before you need it
- Create new shards based on predictable criteria (date, tenant growth)
- Use consistent naming conventions from the beginning
- Design for eventual consistency in cross-shard operations
- Consider deploying one Worker per tenant for better isolation
- Plan shard transitions before hitting the 10GB limit to avoid emergency migrations
Automating Database Scaling
One of the most powerful aspects of this architecture is that it enables automated database scaling. Since the system is designed to work with a standardized naming convention and binding structure, you can build automation around adding new shards:
- Monitoring: Use Worker Cron triggers to regularly query database sizes and monitor usage trends
- Alerting: Set up automatic alerts as databases approach the 10GB limit
- Provisioning: Automate the creation of new D1 databases with the appropriate naming convention
- Deployment: Generate pull requests to add new database bindings to your Worker configuration
- Testing: Automatically validate that new shards are accessible before cutting over
This automation removes the operational burden of manual database scaling, making it feasible to start with a simple project and let it grow organically without requiring architectural redesigns as scale increases.
Design Philosophy
A core motivation behind this architecture is enabling scale-without-redesign. Too often, developers start with a simple architecture that works well for early stages but requires complete redesigns as the application grows.
This sharding approach allows you to:
- Start simple: Begin with a single database for MVP or early-stage products
- Scale incrementally: Add shards as needed without changing your application logic
- Evolve naturally: Let your data architecture grow with your user base
- Avoid refactoring: The same system works from prototype to production scale
By designing for eventual scale from the beginning—but implementing only what you need today—you can build systems that grow without painful rewrites.
Conclusion
This sharding architecture provides a scalable solution for Cloudflare D1 databases that works within the constraints of the Workers environment. By embedding routing information directly in IDs, we avoid the need for central coordination while maintaining the ability to scale horizontally.
The approach is particularly well-suited for applications with:
- Multi-tenant requirements (especially with the worker-per-tenant approach)
- Time-series data patterns
- Regional distribution needs
- Predictable growth patterns
- Applications approaching the 10GB D1 database limit
While the implementation details may vary based on specific requirements, this pattern provides a solid foundation for building systems that can grow beyond the limits of a single database instance.