Health · AWS, in this exercise

If I were FinOps at Doctolib

From Doctolib's write wall to a naive Aurora cluster at about $421,000 a month at list, then lever by lever to about $186,000.

By Mathieu Kerbel · Doctolib
Peak load667,000 queries a secondAssumed
Load per vCPU248 queries a secondInferred
Writes7% of queriesAssumed
Version 0, baseAbout $421,200 a monthAssumed
After every lever, baseAbout $185,700 a monthAssumed

This is not a serious proposal for Doctolib's stack. I take the problem the company has described in public, make assumptions around it, and walk towards an answer with cost, latency and money in view. Everything runs on AWS, by choice. Doctolib's real platform is its own, and I make no claim about it or about its bill.

Every amount below prices my exercise. Prices are AWS list prices in USD before tax, for the EU (Paris) Region, from the offer files in the sources. List prices overstate what a company of this size pays, and private discounts are not public. Each figure is tagged public, inferred or assumed, and each comes from one script that applies every lever only to what the previous lever left.

The problem

Doctolib has talked in public for five years about one problem: how far one PostgreSQL database goes. In 2022, talks described one writer and 13 replicas and up to 20 million queries a minute. The team said it was close to the physical limits of Aurora, and that one writer was no longer enough. In October 2025, a podcast gave the state of things:

We have 10 writers today and each writer can have up to 15 readers

AWS, On Rails podcast, October 2025

The same episode says the main writer holds about 40 terabytes, which is "not really the issue": "That's more the IO the problem." Readers scale by adding instances. A writer does not. So the wall is writes, and I take that as my starting point. How the company solved it is not my subject here. How far money and latency can go from a careless start is.

What I assume

One public ratio anchors the model. The 2021 retrospective names db.r5.24xlarge, at 96 vCPUs. Fourteen of those at 20 million queries a minute is 248 queries a second per vCPU. It is an observed load in a real fleet, headroom included, and the class in 2022 is my inference.

The scale behind the exercise

Quantity Low Base High Tag
Peak queries a second 500,000 667,000 1,000,000 Assumed: ×1.5, ×2, ×3 on the public 2022 figure
Queries per vCPU at peak 248 248 248 Inferred
Writes 7% 7% 7% Assumed
Average over peak 40% 40% 40% Assumed
Billed I/O per query 0.1 0.3 0.6 Assumed
Data 60 TB 90 TB 130 TB Assumed: about 40 TB on the main writer is public
Traffic through NAT, a month 20 TB 50 TB 100 TB Assumed

I chose 7% because it puts a writer at its limit in 2022, when the team said one writer was no longer enough. On the same ratio, one 192-vCPU writer carries about 47,600 queries a second (assumed as a limit).

The fleet here is sized from that ratio, so it is smaller than what 10 writers with up to 15 readers each would suggest. It says what a load of this size needs in my model, not what runs anywhere. Aurora, ElastiCache, DynamoDB, S3 and NAT gateways all have Paris price rows, so nothing here leaves the Region. I price one Region only.

Version 0: the naive build

The first version is the obvious one: one Aurora cluster, one writer and as many readers as the load asks, on the largest Intel class, at $32.16 an hour. No cache, no pooler, Aurora Standard storage, S3 traffic through NAT gateways, and no commitment.

Version 0: one writer takes every write, so it is the wall. The dots and their paces are an illustration.
Version 0: one writer, 14 readers, nothing in frontThe application in the Paris Region sends every write to one Aurora writer and every read to one of 14 readers, on db.r7i, in any of three zones. There is no cache and no pooler, and S3 traffic leaves through NAT gateways.Paris RegionAuroraZone 1Zone 2Zone 3any zoneApplicationNAT gatewaysAmazon S3Writerdb.r7iReadersReadersReaders14 readers, db.r7i
Version 0: one writer, 14 readers, nothing in front My exercise, not Doctolib's architecture. About $421,200 a month, at list.

Version 0, USD a month at list, rounded

Line Low Base High
Readers 234,800 (10) 328,700 (14) 469,500 (20)
Writer 23,500 23,500 23,500
Billed I/O ($0.22 per million) 11,600 46,300 138,800
Storage ($0.11 per GB-month) 6,600 9,900 14,300
Cross-zone traffic ($0.01 per GB) 7,000 9,300 14,000
NAT gateways 1,100 2,600 5,100
Backup beyond the free allocation 300 1,000 2,900
Total 284,900 421,200 668,100
Writer at peak 73.5% 98% 147%

Instances are 84% of the base. The base writer sits at 98% at peak, which is the wall. The high column does not fit at all: 20 readers is over Aurora's limit of 15, and its writer is past its limit. Version 0 is cheap on paper because it cannot carry the load, so the page has to spend money somewhere.

Optimisation 1: a read cache, and its hit rate

The problem. Fourteen readers answer the same availability questions again and again.

The change. The team added a cache for availability after its 2021 peak, and I start there. Six cache.r8g.4xlarge Valkey nodes, at about $7,225 a month. I assume 30% of reads are cacheable, at an 80% hit rate, so 24% of reads leave the readers.

Latency. A hit is about 1 ms against about 5 ms on a reader (assumed). The real cost is staleness: an old slot is a failed booking.

Dollars. Readers fall from 14 to 10, and it saves about $99,100. That leaves about $322,200. At a 60% hit rate it saves about $72,500. At 90%, about $100,600, since no further whole reader goes. The trap is the cold cache. Those 10 readers carry 1,920 vCPU, and with the cache empty at peak the reads ask for 2,500, or 130%. A restart at the wrong hour takes the fleet down, so the cache needs warming, and it now counts as capacity.

After the read cache: fewer readersAmazon ElastiCache for Valkey, six nodes, now answers cacheable reads beside the application, and the readers fall from 14 to 10.Paris RegionAuroraZone 1Zone 2Zone 3any zoneApplicationNAT gatewaysAmazon S3Writerdb.r7iReadersReadersReaders10 readers, db.r7iElastiCacheValkey
  • Added
  • Replaced
After the read cache: fewer readers My exercise, not Doctolib's architecture. About $322,200 a month left, at list.

Optimisation 2: the number of writers, the lever that adds money

The problem. Nothing above touches writes. The base writer is still at 98% at peak.

The change. Split by domain, moving "a group of tables that are very related to each other", to keep joins and foreign keys. I treat 60% at peak as safe, and price three counts.

How many writers, from the state after the cache

Writers Largest writer at peak At twice the load Adds a month
2 58.8% 117.6% 23,477
4 53.9% 107.8% 46,954
10 39.2% 78.4% 70,430

Latency. A query inside a domain is unchanged. A join across domains becomes two queries in the application (assumed: a few milliseconds, and a project for each one).

Dollars. I take two, the cheapest count under 60%. It adds about $23,500, which leaves about $345,600. This is the one lever here that adds real money, and it is the reason the others are possible. Two clear today's wall in my model and fail at twice the load, so two is only the cheapest answer to this one row. Ten is the count in this table that survives a doubling, which is what a growing company has to plan for, and it costs about $47,000 a month more. That figure comes from my fleet, not a real one, and it leaves out isolation and blast radius, which this table cannot price.

After the second writer: two clusters, split by domainA second writer and its cluster join the first, split by domain, so writes no longer rest on one instance. This is the one lever that adds real money.Paris RegionAuroraZone 1Zone 2Zone 3any zoneApplicationNAT gatewaysAmazon S3Writerdb.r7iReadersReadersReaders10 readers, db.r7i, two clustersElastiCacheValkeyWriter 2db.r7i
  • Added
  • Replaced
After the second writer: two clusters, split by domain My exercise, not Doctolib's architecture. About $345,600 a month left, at list.

Optimisation 3: connection pooling

The problem. A large Rails monolith holds many idle connections, and each one costs memory on the database.

The change. Two options, and the first is a trap. RDS Proxy bills $0.022 per vCPU-hour on the fleet behind it: about $37,000 a month on 2,304 vCPU. It also pins connections on SET, on prepared statements and on nextval, which erodes what it was bought for. So I run a pooler on six small Graviton nodes, at about $417. Transaction pooling has its own price: session state and prepared statements have to change in the application. I assume 8% of database capacity comes back.

Latency. One more hop, under a millisecond (assumed). The pages I read state no figure for RDS Proxy.

Dollars. In the three columns it frees no whole reader yet, so it costs $417, which leaves about $346,100. I keep it for the 8% of headroom, and it is the only lever I keep at a loss.

After pooling: a pooler in front of the databaseA connection pooler on six small Graviton nodes now sits between the application and the database, in transaction mode. RDS Proxy stays out.Paris RegionAuroraZone 1Zone 2Zone 3any zoneApplicationNAT gatewaysAmazon S3Writerdb.r7iReadersReadersReaders10 readers, db.r7i, two clustersElastiCacheValkeyWriter 2db.r7iPoolerGraviton nodes
  • Added
  • Replaced
After pooling: a pooler in front of the database My exercise, not Doctolib's architecture. About $346,100 a month left, at list.

Optimisation 4: moving a domain out

The problem. Some domains have no need for PostgreSQL's joins and pay for its writer anyway.

The change. An append-heavy domain moves to DynamoDB, taking 10% of writes, 8% of reads and 5 TB. At Paris on-demand rates that costs about $11,020 a month. Foreign keys and transactions across that boundary go, and the migration is a real project.

Latency. AWS states single-digit milliseconds for DynamoDB, and I assume about the same as a reader. The gain is on the writer: the largest falls from 54.1% to 48.7% at peak.

Dollars. It depends on rounding, which is the honest result. In the low and high columns it frees a reader and saves about $16,600 and $18,100. In the base it frees none, and costs about $6,900 a month more, so I leave it out. $346,100 stands. Move a domain to buy write headroom, or because the model suits it. As a saving, it needs the volume to free a whole instance.

Optimisation 5: readers follow the day

The problem. The fleet is sized for a peak, and the average is 40% of it.

The change. Aurora Auto Scaling adds and removes replicas. I assume 30% of the reader hours above one reader per cluster go at night and at weekends.

Latency. A new reader starts with a cold buffer cache, so its first minutes are slow, and a peak comes fast (assumed). Scale out ahead of the morning, on a schedule.

Dollars. About $56,300, which leaves about $289,700. Only the cache and the commitment save more, and a schedule is the cheapest of the three to undo.

After scheduling: the readers follow the dayAurora Auto Scaling now adds and removes readers, so reader hours go at night and at weekends. Scale-out runs ahead of the morning, on a schedule.Paris RegionAuroraZone 1Zone 2Zone 3any zoneApplicationNAT gatewaysAmazon S3Writerdb.r7iReadersReadersReaders10 readers, Aurora Auto ScalingElastiCacheValkeyWriter 2db.r7iPoolerGraviton nodes
  • Replaced
After scheduling: the readers follow the day My exercise, not Doctolib's architecture. About $289,700 a month left, at list.

Optimisation 6: Graviton classes

The problem. Version 0 runs on Intel, because that is where I started it.

The change. Move managed services first. db.r8g against db.r7i is $30.53 against $32.16 at the largest size, 5.1% less at list. Test on one reader, then fail over.

Latency. I count nothing. A throughput gain is likely but unmeasured here, and if one appears it frees readers on top of this.

Dollars. About $11,400, which leaves about $278,300.

After Graviton: every instance on db.r8gThe writers and the readers move from db.r7i on Intel to db.r8g on Graviton. Nothing else moves.Paris RegionAuroraZone 1Zone 2Zone 3any zoneApplicationNAT gatewaysAmazon S3Writerdb.r8gReadersReadersReaders10 db.r8g, Aurora Auto ScalingElastiCacheValkeyWriter 2db.r8gPoolerGraviton nodes
  • Replaced
After Graviton: every instance on db.r8g My exercise, not Doctolib's architecture. About $278,300 a month left, at list.

Optimisation 7: reads stay in their zone

The problem. Traffic between application and database across zones bills $0.01 per GB. With 2 KB a query and two thirds of traffic crossing, that adds up.

The change. Route each read to a reader in the same zone, with zone-aware routing in the application, since the reader endpoint does not do it. I assume 70% of cross-zone read traffic goes. Writes still cross, and a failed zone brings it all back.

Latency. A slightly shorter hop (assumed: under a millisecond).

Dollars. About $4,600, which leaves about $273,600.

After zone-aware reads: each read stays in its zoneThe application now routes each read to a reader in its own zone, since the reader endpoint does not. Writes still cross, and a failed zone brings it all back.Paris RegionAuroraZone 1Zone 2Zone 3same zoneApplicationzone-awareNAT gatewaysAmazon S3Writerdb.r8gReadersReadersReaders10 db.r8g, Aurora Auto ScalingElastiCacheValkeyWriter 2db.r8gPoolerGraviton nodes
  • Replaced
After zone-aware reads: each read stays in its zone My exercise, not Doctolib's architecture. About $273,600 a month left, at list.

Optimisation 8: cold rows to Amazon S3

The problem. Old rows sit on storage that exists for live queries.

The change. Move 30 TB to Amazon S3 as columnar files, compressed three to one (assumed), in Glacier Instant Retrieval at $0.005 per GB. The files are large, so the 128 KB rule does not bite, and the 90-day minimum suits data that never changes.

Latency. A cold row goes from milliseconds to a query engine's seconds (assumed).

Dollars. About $3,600, which leaves about $270,100. On 90 TB, storage was never the problem, or as the podcast puts it: "Not because of the amount of data." Do this for query speed if it helps. As a cost lever, at this scale, it is noise.

After cold rows: old data leaves AuroraThirty terabytes of old rows move out of Aurora to Amazon S3 as columnar files, in Glacier Instant Retrieval.Paris RegionAuroraZone 1Zone 2Zone 3same zoneApplicationzone-awareNAT gatewaysAmazon S3Glacier InstantWriterdb.r8gReadersReadersReaders10 db.r8g, Aurora Auto ScalingElastiCacheValkeyWriter 2db.r8gPoolerGraviton nodes
  • Added
  • Replaced
After cold rows: old data leaves Aurora My exercise, not Doctolib's architecture. About $270,100 a month left, at list.

Optimisation 9: S3 traffic off the NAT gateways

The problem. 50 TB a month pays $0.05 per GB to reach a service inside the Region.

The change. A gateway endpoint for S3 has no charge. I assume 80% of that traffic is S3.

Latency. None worth counting.

Dollars. $2,000, which leaves about $268,100. It takes an afternoon, and that is why it is on this page.

After the S3 endpoint: S3 traffic off the NAT gatewaysS3 traffic now reaches Amazon S3 through a gateway endpoint. The NAT gateways stay for the traffic that is not S3.Paris RegionAuroraZone 1Zone 2Zone 3same zoneApplicationzone-awareNAT gatewaysAmazon S3Glacier InstantWriterdb.r8gReadersReadersReaders10 db.r8g, Aurora Auto ScalingElastiCacheValkeyWriter 2db.r8gPoolerGraviton nodesGatewayendpoint
  • Added
  • Replaced
After the S3 endpoint: S3 traffic off the NAT gateways My exercise, not Doctolib's architecture. About $268,100 a month left, at list.

Optimisation 10: I/O-Optimized against Standard

The problem. Doctolib said "That's more the IO the problem", so this is the lever people reach for.

The change, and why I do not make it. AWS calls it "the best choice when your I/O spending is 25% or more of your total Aurora database spending". Here I/O is 14%. Switching adds 29.87% to the instance rate, or about $63,900, and $8,280 of storage, to remove $35,929 of I/O. It costs about $36,300 a month more.

Latency. None stated in what I read.

Dollars. $0. $268,100 stands. Break-even sits at about 124,900 billed I/O a second, as a monthly average, against my 62,100. That is the first number I would look up from the inside, because "IO" in a podcast may mean something else. Reserved Instances still apply: AWS says I/O-Optimized consumes 30% more normalized units. In the base the switch still costs about $16,700 a month after commitments. In the high column it saves $3,306 before commitments and about $27,300 after them, so there I take it.

Optimisation 11: Reserved Instances

The problem. Nine of the twelve instances run every hour of the year.

The change. Commit to the steady baseline, last. A reservation is tied to its class type, so buying it on Version 0's Intel fleet of 15 would have paid for instances that are now 12, on another class. One year, No Upfront, 33% off, on the two writers and seven readers.

The commitment on the same nine instances, USD a month

Option Discount Saving
Database Savings Plan, 1 year 20% 40,114
Reserved Instances, 1 year No Upfront 33% 66,188
Reserved Instances, 3 years No Upfront 53% 106,302

Latency. None.

Dollars. About $66,200, which leaves about $201,900. The plan is the flexible choice and three years the deep one, but three years is a long bet on a class while the fleet is still splitting. The Paris offer lists that three-year No Upfront rate, while AWS's guide says No Upfront is a one-year option, so I would confirm it before planning on it.

Optimisation 12: negotiation

The problem. A company at this scale does not pay list.

The change. A private discount, and I assume 8%. Nothing about any real agreement is public, and it would apply to much more than this data tier.

Latency. None.

Dollars. About $16,200, which leaves about $185,700.

Where it lands

Where it lands, base scenario: cache, readers, two writers, cold rows. The dots and their paces are an illustration.

Start and end, USD a month, rounded

Low Base High
Version 0 284,900 421,200 668,100
After every lever 133,600 185,700 283,300
Cut 53.1% 55.9% 57.6%
Instances, from and to 11 to 9 15 to 12 21 to 16

The base ends on two writers and 10 readers, with the largest writer at 54% at peak. The order did the work. Four levers carry it: the cache, the schedule, the commitment and the discount. Two of the twelve stay out of the base, and one costs $417 on purpose. Unlike a token bill, this one never collapses in one step. It is won instance by instance, and rounding decides more than I expected.

The funnel

USD a month left after each step, base scenario, AWS list prices in Paris. The 12 levers are grouped into 8 steps after Version 0, and each acts only on what the step before it left.
  1. Version 0Base · One writer, 14 readers, largest Intel class$421,200 a month
  2. Cache, writers, poolingOptimisation · Cache saves $99,100; the rest adds $23,900$346,100 a month leftsaves $75,10017.8% of the base
  3. One domain outOptimisation · Does not pay here: $6,900 a month more$346,100 a month leftsaves $00% of the base
  4. Readers follow the dayOptimisation · 30% of the reader hours above the floor$289,700 a month leftsaves $56,40013.4% of the base
  5. Graviton classesOptimisation · 5.1% at list, no throughput gain counted$278,300 a month leftsaves $11,4002.7% of the base
  6. Zone, cold rows and NATOptimisation · Three small levers, about $10,200$268,100 a month leftsaves $10,2002.4% of the base
  7. I/O-OptimizedOptimisation · Does not pay here: $36,300 a month more$268,100 a month leftsaves $00% of the base
  8. Reserved InstancesCommitment · 1 year, 33% off, on 9 of 12 instances$201,900 a month leftsaves $66,20015.7% of the base
  9. Negotiated discountNegotiation · 8% assumed$185,700 a month leftsaves $16,2003.8% of the base

Your own numbers will differ, and yours can be measured. The calculator prices your stack line by line, and a FinOps audit starts from your real billing data, in the same order: reduce first, then commit, then negotiate.

What I cannot see from outside

  • The real load and its mix. The last public query figure dates from 2022. My 7% of writes is a calibration, not a measurement.
  • What a vCPU really carries. The 248 comes from one public moment, on an older class I inferred.
  • The real I/O rate. It decides lever 10. "IO" in a podcast may mean query load, not billed storage I/O.
  • The storage configuration. If a fleet already runs on I/O-Optimized, lever 10 is already taken and its instance rates are about 30% higher.
  • Cacheable share and hit rate. I assumed both, and the largest optimisation rests on them.
  • Why ten writers. The evaluation of 2022 is public, its conclusion is not. Isolation and blast radius may matter more than this page's table.
  • Everything outside this data tier. The Kubernetes fleet, Kafka, Couchbase, CI and the feature-team databases are not here. Neither is a second Region.

Sources

  1. On Rails podcast: optimizing Rails tests at Doctolib scale Ruby on Rails, talk, accessed October 30, 2026.
  2. Monday, July 12 at Doctolib: a Retrospective Doctolib, engineering blog, accessed October 30, 2026.
  3. Comment Doctolib a géré le pic de charge du 12 juillet Le Monde Informatique, press, accessed October 30, 2026.
  4. Doctolib a besoin d'une base de données plus puissante Notes from Devoxx France 2022, talk, accessed October 30, 2026.
  5. Duck Conf 2022: compte rendu du talk de Doctolib OCTO, talk, accessed October 30, 2026.
  6. Comment Doctolib compte rendre son monolithe modulaire LeMagIT, press, accessed October 30, 2026.
  7. Amazon Aurora storage AWS, documentation, accessed October 30, 2026.
  8. Amazon Aurora Auto Scaling with Aurora Replicas AWS, documentation, accessed October 30, 2026.
  9. Reserved DB instances for Amazon Aurora AWS, documentation, accessed October 31, 2026.
  10. Avoiding pinning an RDS Proxy AWS, documentation, accessed October 31, 2026.
  11. What is Amazon DynamoDB? AWS, documentation, accessed October 16, 2026.
  12. AWS Price List: Amazon RDS, eu-west-3 (offer file of October 29, 2026) AWS, pricing page, accessed October 31, 2026.
  13. AWS Price List: Database Savings Plans, eu-west-3 (offer file of October 26, 2026) AWS, pricing page, accessed October 31, 2026.
  14. AWS Price List: Amazon ElastiCache, eu-west-3 (offer file of October 28, 2026) AWS, pricing page, accessed October 31, 2026.
  15. AWS Price List: Amazon DynamoDB, eu-west-3 (offer file of September 11, 2026) AWS, pricing page, accessed October 16, 2026.
  16. AWS Price List: Amazon S3, eu-west-3 (offer file of October 15, 2026) AWS, pricing page, accessed October 31, 2026.
  17. AWS Price List: Amazon EC2, eu-west-3 (offer file of October 29, 2026) AWS, pricing page, accessed October 31, 2026.

Your own bill, read from the inside.

Free audit within 48 hours of receiving your billing exports, then paid only on the savings.

Get a free audit