By Oleksii Tkachuk , Rajiv Shringi , Chris Lohfink , Abe Ratnofsky , Kartik Sathyanarayanan
Introduction
In an earlier post , we introduced the TimeSeries Abstraction, our high-throughput, low-latency platform designed to store and query temporal event data at scale. It has since become the operational backbone for Netflix’s largest datasets, which now span multiple petabytes. However, managing such large datasets in hot storage, tuned for low-millisecond latency access , is not cheap. At Netflix, cost-efficiency is a continuous discipline rather than a temporary initiative. This post shares two solutions that significantly reduce storage costs by continuously moving older, less frequently accessed data into cheaper object storage:
Solution 1 : Uses Apache Kafka and Flink to stream data directly to S3
Solution 2 : Leverages Cassandra backups in S3 and pluggable Memtables to read data directly from S3
We’ll start with the Kafka/Flink-based design, then describe how we evolved to a native Cassandra-based cold tier. Challenges
As Netflix’s TimeSeries datasets have expanded, we’ve encountered several compounding challenges:
Growing retention demands : Users often push for increasing data retention to serve a broader range of use cases, which in turn increases the storage costs. Escalating costs : Maintaining massive, dense clusters for infrequently accessed historical data is prohibitively expensive. Sparse historical access: Most queries target recent events, but older data must remain readily available for occasional audits and investigations. Operational strain : High storage density on hot nodes complicates scaling, deployment, and cluster stability. Latency requirements : Even historical data sometimes requires sub-second query performance for internal tooling and analysis. This creates a clear opportunity: keep the most recent data on fast storage and migrate older, rarely read data to cheaper storage while preserving a consistent API . Done well, this can meet user and operational needs while greatly reducing our infrastructure spend. TimeSeries Abstraction
Before we dive into the cold storage solutions, here is a quick recap of how TimeSeries partitions its dataset into multiple tables, each covering a window of time we call Time Slices . Time Slices and Immutable Windows This enables several key optimizations, most notably allowing a table to become immutable once it crosses the write boundary, which lets us safely apply further optimizations. For more details, refer to our previous post . This slice information is critical and is maintained in a dedicated metadata table:
Time Slice Metadata Storage Creating new Time Slices and maintaining their metadata is a primary responsibility of the TimeSeries control plane. The TimeSeries data plane uses this information to correctly route the read and write traffic to the provisioned tables. TimeSeries Data and Control Plane With that background, let’s look at the different solutions we built for data tiering. Solution 1: Streaming with Apache Kafka and Flink
Our first solution followed a familiar streaming pattern using Apache Kafka and Flink, where we streamed data to S3 in realtime while simultaneously persisting it to Cassandra. Streaming with Apache Kafka and Flink Getting Data Into S3 Efficiently
To optimize data streaming to S3, the system hash-partitions Time Series IDs into buckets. This partitions the stream and lets dedicated Flink tasks handle each bucket, ultimately batching and reducing the write throughput to S3. Flink workers batch events into Parquet files grouped by event time within the hour. These files are great for high-throughput streaming writes, but not for low-latency reads. Enter the Compaction job. Compaction Job to Optimize Reads
The compaction job runs daily and merges small Parquet files into a single, optimized daily Parquet file per namespace. The job further optimizes the Parquet files in the following ways:
Small Row Groups : Creates small (4–8 MB) row groups to minimize lookup cost. We can then use the Parquet Java library to perform range reads of row groups from S3. This is intentionally well below Parquet’s recommended row group floor  — smaller groups were better because our reads are dominated by point/range lookups rather than full scans. Event Sorting : Sorts events within a row group by time, eliminating brute-force scans when querying for a time range. Bloom Filters : Caches Bloom filters and file metadata in EVCache , allowing the TimeSeries query planner to efficiently skip unnecessary reads from S3. Compacted and Sorted Parquet File Layout Once the compaction job completes, all compacted files per partition are recorded in the TimeSeries control plane, and the hot storage slices can now be truncated! On some of our largest datasets, this job typically takes about 12 hours, prioritizing lower compute costs over execution speed. Compaction Burndown Per Day Streaming Cold Tier Read
Once compacted files are optimized in cold storage, they are ready to be read. The TimeSeries data plane fans out a given query across both the hot and cold tiers and merges their result. The users still use the same query interface:
TimeSeries Tiered Storage Read Path The Cold Tier Data Plane uses compaction metadata for aggressive planning and pruning, which dramatically reduces the S3 query fan-out. As a result, retrieving a full day of data requires at most three sequential S3 reads, even in the uncommon case where an ID spans two row groups. This yields a p50 latency of ~150 ms and a p99 latency of ~500 ms per read. Cold Tier Read Latency in Seconds At the time of writing, the streaming cold tier implementation was processing up to 1,250 reads per second from cold storage globally:
Cold Tier Read Requests Per Second Why not stop here? The streaming cold tier has delivered real savings, shrinking both Cassandra’s EBS footprint and the Cassandra backup footprint in S3. But the streaming infrastructure itself is expensive and adds moving pieces that widen the team’s on-call scope:
Additional Infrastructure and Operational Cost We wanted to improve this further and asked ourselves:
Can we avoid streaming data into the object store and the associated data transfer costs? Can we reduce the compute and operational overhead of new components? That led to a more radical question: Can we read directly from existing Cassandra backups in S3? Solution 2: Cassandra Cold Storage for Immutable Data
At Netflix, we back up Cassandra data into S3 at regular intervals for disaster recovery. The idea for tiering is simple:
Read directly from these S3 backups instead of paying a separate data transfer cost to S3. Once TimeSeries marks a hot Time Slice as ready for cold storage, we can customize Cassandra to read directly from its backups, allowing us to delete the on-disk data copy. This would also incur minimal changes to the TimeSeries read path! New TimeSeries Read Path To make this viable in production, we had to answer a series of “How…?” questions. How Can We Read the Backups Efficiently? We introduced a new compaction strategy in Cassandra, which we call ReadOnlyCompactionStrategy (ROCS). Here is how it works:
Once a time slice crosses the user-configured cold-tier boundary , TimeSeries stops accepting writes for that time slice making it immutable. This immutability unlocks significant optimization opportunities:
SSTable overlap: Standard strategies (LCS, TWCS) often produce SSTables that overlap in the token space, and so a single partition key may exist across multiple files. Read fan‑out: Because a read must touch every SSTable covering the key’s token range, this overlap turns a single point-read into multiple lookups, increasing latency across all tiers…