<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Rufus Kehinde on Medium]]></title>
        <description><![CDATA[Stories by Rufus Kehinde on Medium]]></description>
        <link>https://medium.com/@dtechwizard?source=rss-612ae3cffe2d------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*dmbNkD5D-u45r44go_cf0g.png</url>
            <title>Stories by Rufus Kehinde on Medium</title>
            <link>https://medium.com/@dtechwizard?source=rss-612ae3cffe2d------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Wed, 04 Nov 2026 23:37:54 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@dtechwizard/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Partition It Before It Hurts: The Tradeoffs and Security Traps Nobody Tells You]]></title>
            <link>https://dtechwizard.medium.com/partition-it-before-it-hurts-the-tradeoffs-and-security-traps-nobody-tells-you-f06121702bbc?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/f06121702bbc</guid>
            <category><![CDATA[data-privacy]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[system-design-interview]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Wed, 04 Nov 2026 14:07:49 GMT</pubDate>
            <atom:updated>2026-11-04T14:07:49.558Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KKWkpXgkf1o5TKEqX3aUtQ.jpeg" /></figure><p>A school keeps its exam scripts in one giant cabinet. Every script since the school opened is in there, in the order it arrived. For years this works. Then one morning the head asks for this term’s scripts, and the clerk spends an hour searching a cabinet that holds thirty years of paper.</p><p>Nothing is broken. The clerk is not slow. The cabinet is simply built for keeping everything, not for finding what matters today.</p><p>So the school makes two changes. First, it adds drawers, one per year, so the clerk opens only the drawer that matters. Second, it takes the oldest drawers to the store room, where they are safe and cheap to keep, and out of the way of daily work. When a year’s retention period ends, the school removes the whole drawer in one move, instead of shredding it page by page.</p><p>That is partitioning and archiving. A database table gets the same treatment, and this post covers how it works in PostgreSQL, SQL Server, Oracle and MongoDB, what it costs, and what it means for security.</p><p>In a previous post <a href="https://medium.com/@dtechwizard/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b">here</a>, we saw indexes, a cabinet of cards that points at the data. In a previous post <a href="https://medium.com/@dtechwizard/your-database-will-tell-you-exactly-why-it-is-slow-most-people-never-ask-2b383db8da70">here</a>, we learned to read query plans. Partitioning works at a different level: instead of making the search faster, it makes the pile smaller.</p><p>Partitioning in plain words<br>Partitioning splits one large table into smaller pieces, called partitions, using a key such as the order date. To the application it still looks like one table. PostgreSQL supports range, list and hash partitioning <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. Range partitions use an inclusive lower bound and an exclusive upper bound, so a partition for October runs from 1 October up to, but not including, 1 November<a href="https://postgresql.org/docs/current/ddl-partitioning.html"> [S1]</a>.</p><p>The first payoff is partition pruning. When your query filters on the partition key, the database can prove which partitions cannot contain the rows and skip them <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. SQL Server calls it partition elimination and says the engine might skip some partitions when the query predicate references the partitioning column <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>. Oracle gives a clear example: a query for one week of a two-year weekly-partitioned Orders table accesses one partition instead of 104, and Oracle says pruning can often improve query performance by several orders of magnitude <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html">[S3]</a>. Notice the wording in each: might, can, often. The speed-up depends on the query filtering on the partition key.</p><p>The second payoff is removing old data, and it is the one I think teams underrate. PostgreSQL’s manual says dropping a partition with DROP TABLE or DETACH PARTITION is far faster than a bulk operation, and avoids the VACUUM overhead of a bulk DELETE <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. SQL Server lets you switch individual partitions out of a table and into an archive table <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>. Oracle describes a rolling window, where a new partition is added by an exchange load, which is much more efficient than modifying the entire table <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html">[S3]</a>.</p><p>The four databases, side by side<br>1. PostgreSQL needs you to create the partitions yourself. Rows that fit no partition cause an error unless you define a default partition <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. A monthly setup looks like this:</p><p><em>CREATE TABLE orders (<br> id bigint,<br> order_date date NOT NULL,<br> seller_id bigint,<br> total numeric,<br> PRIMARY KEY (id, order_date)<br>) PARTITION BY RANGE (order_date);</em></p><p><em>CREATE TABLE orders_2026_10 PARTITION OF orders<br> FOR VALUES FROM (‘2026–10–01’) TO (‘2026–11–01’);</em></p><p><em>— retire a month later<br>ALTER TABLE orders DETACH PARTITION orders_2026_10 CONCURRENTLY;<br> — back it up or process it, then:<br> — DROP TABLE orders_2026_10;</em></p><p>Note the primary key includes the partition key. That is not optional, and I come back to it under the tradeoffs. DETACH PARTITION CONCURRENTLY takes only a SHARE UPDATE EXCLUSIVE lock on the parent instead of ACCESS EXCLUSIVE, and a detached partition stays a regular table you can still read, so you can back it up or process it before dropping it <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>.</p><p>2. SQL Server builds partitioning from two objects. A partition function sets the boundaries, and a partition scheme maps the partitions to filegroups <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>. Microsoft’s own example uses RANGE RIGHT, so each boundary date is the lower bound of its partition <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>:</p><p><em>CREATE PARTITION FUNCTION pf_orders_month (datetime)<br>AS RANGE RIGHT FOR VALUES (‘2026–09–01’, ‘2026–10–01’, ‘2026–11–01’);</em></p><p><em>CREATE PARTITION SCHEME ps_orders_month<br>AS PARTITION pf_orders_month ALL TO ([PRIMARY]);</em></p><p><em>— later, move the oldest partition out into an archive table<br>ALTER TABLE dbo.orders SWITCH PARTITION 1 TO dbo.orders_archive;</em></p><p>The engine supports up to 15,000 partitions per table or index, and since SQL Server 2016 SP1 the feature is no longer limited to certain editions <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>. The SWITCH statement needs an archive table with a matching structure, and I did not verify the full list of requirements, so check Microsoft’s SWITCH documentation before you rely on it.</p><p>3. Oracle partitions are transparent to applications: from the application’s perspective, a partitioned table is identical to a nonpartitioned one, with no changes needed to SQL queries or DML <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html">[S3]</a>. Range partitioning is the most common type and is often used with dates <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html">[S3]</a>. Interval partitioning goes a step further and tells the database to automatically create partitions of a specified interval when data arrives beyond the existing ranges <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html">[S3]</a>:</p><p><em>CREATE TABLE orders (<br> id NUMBER, order_date DATE NOT NULL, seller_id NUMBER, total NUMBER<br>)<br>PARTITION BY RANGE (order_date)<br>INTERVAL (NUMTOYMINTERVAL(1, ‘MONTH’))<br>(PARTITION p_start VALUES LESS THAN (DATE ‘2026–01–01’));</em></p><p>Oracle’s recommendations add some pitfalls. You cannot manually add partitions to an interval-partitioned table, and hash partitions are not an effective way to manage historical data, because only equality or IN-list predicates are supported for pruning <a href="https://docs.oracle.com/en/database/oracle/oracle-database/21/vldbg/recommendations-partition-strategy.html">[S4]</a>. Oracle also notes a licensing detail: in its 11g licensing table, Partitioning is an Enterprise Edition option marked as extra cost <a href="https://docs.oracle.com/cd/E17116_01/doc/license.112/e10594/editions.htm">[S5]</a>. That page is old, so check the current terms for your version before you plan around it.</p><p>4. MongoDB is different. It does not split a collection into partitions the way these three do. Splitting data across machines is sharding, which we cover later. For retention, MongoDB gives you the TTL index. A TTL index is a single-field index on a date that removes documents after a set number of seconds <a href="https://mongodb.com/docs/manual/core/index-ttl/">[S6]</a>:</p><p><em>// delete documents 90 days (7,776,000 seconds) after createdAt<br>db.events.createIndex({ createdAt: 1 }, { expireAfterSeconds: 7776000 })</em></p><p>For time series data, a time series collection can remove expired data automatically, and it does so by whole bucket: a bucket goes only when every document in it has expired <a href="https://mongodb.com/docs/rapid/core/timeseries/timeseries-automatic-removal">[S7]</a>.</p><p>Archiving is not deleting<br>Archiving means moving rarely used data to somewhere cheaper and safer. In PostgreSQL that is a detached partition you back up and then drop, or move to cheaper storage, which the manual lists as a benefit <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. In SQL Server it is the archive table you switch into <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>. Oracle’s guide describes spreading groups of data across different types of storage and managing them individually <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html">[S3]</a>. In MongoDB it is a copy you take out before the TTL removes the original.</p><p>The point is the decision. Each piece of old data should have a rule: keep it live, keep it archived, or delete it, and a date for each.</p><p>The tradeoff<br>Partitioning looks like free speed. It is not. Here is what it costs.</p><p>- Only queries that filter on the key get faster. Microsoft is direct about this: queries that cannot skip partitions could take longer to execute as the number of partitions increases, TOP, MAX or MIN queries on non-partitioning columns might get slower because every partition must be evaluated, and partitioning rarely improves performance in OLTP systems where such queries are common <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>. Joins between tables that are not partitioned on the join field can even be slowed by partitions <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>. Worse, Microsoft says to avoid designs with many hundreds or thousands of partitions unless strictly necessary <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">[S2]</a>.</p><p>- More partitions means more planning work. PostgreSQL’s planner handles partition hierarchies of up to a few thousand partitions fairly well, as long as pruning leaves only a few; planning time and memory grow with the number of partitions left after pruning <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>.</p><p>- It may not be needed at all. PostgreSQL gives a rule of thumb that a table should exceed the server’s physical memory before partitioning pays off <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. If your table is smaller, try an index first, as in a previous post <a href="https://medium.com/@dtechwizard/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b">here</a>, and check your plan, as in a previous post <a href="https://medium.com/@dtechwizard/your-database-will-tell-you-exactly-why-it-is-slow-most-people-never-ask-2b383db8da70">here</a>.</p><p>- Uniqueness gets harder. In PostgreSQL, a primary key or unique constraint on a partitioned table must include all the partition key columns <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. A unique order id alone can no longer be enforced by the database across all partitions. If duplicates are expensive, for example, duplicate payments, that guarantee has to come from somewhere else. This is where the idempotency idea from a previous post <a href="https://medium.com/@dtechwizard/you-think-your-api-is-slow-because-it-takes-time-to-return-a-response-1e0f428c330f">here</a> earns its keep.</p><p>- You must plan the future. PostgreSQL errors on rows with no matching partition unless you define a default one <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>, so someone has to create next month’s partition before the month starts. Oracle’s interval partitioning avoids this, at the cost of the restrictions above <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html">[S3]</a> <a href="https://docs.oracle.com/en/database/oracle/oracle-database/21/vldbg/recommendations-partition-strategy.html">[S4]</a>.</p><p>- It may cost money. Oracle lists Partitioning as an extra-cost option, at least in its 11g documentation <a href="https://docs.oracle.com/cd/E17116_01/doc/license.112/e10594/editions.htm">[S5]</a>.</p><p>- TTL is not a stopwatch. MongoDB’s background task runs every 60 seconds, deletion can start between 0 and 60 seconds after the index is built, and under heavy workloads expired documents may remain for some time <a href="https://mongodb.com/docs/manual/core/index-ttl/">[S6]</a>. Each cycle also has limits: up to 50,000 documents or one second of deleting per index <a href="https://mongodb.com/docs/manual/core/index-ttl/">[S6]</a>.</p><p>My rule: partition when most queries filter on one key, when the table is big, and when you want to remove old data cheaply. Do not partition just because the table feels large.</p><p>The security angle<br>Old data is still data, and “we archived it” is not the same as “we protected it”. Here is what I check.<br>- Keep only what you are allowed to keep. GDPR’s storage limitation principle says personal data should be kept in a form which permits identification for no longer than is necessary for the purposes it is processed for, with longer storage allowed only for archiving in the public interest, research or statistics, subject to appropriate safeguards <a href="https://gdpr-text.com/read/article-5/">[S8]</a>. Commentary on Nigeria’s Data Protection Act 2023 summarizes the same principle: the retention period should not be longer than necessary to achieve the lawful purposes <a href="https://assets.kpmg.com/content/dam/kpmg/ng/pdf/the-nigeria-data-protection-act-2023.pdf">[S9]</a>. That source is a KPMG commentary, not the Act itself, so check the statute or ask your counsel before you quote it as law. Either way, the technical lesson is the same: partitioning by date is what makes a retention rule cheap to enforce.</p><p>- An archive is a copy that needs a guard. A detached PostgreSQL partition remains a regular table you can still access <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. So a forgotten detached table can quietly outlive its retention date. Give every archive an owner, an expiry date and the same protections as live data: encryption at rest, access rules and logging. GDPR also requires processing that ensures appropriate security, including protection against unauthorised processing and accidental loss <a href="https://gdpr-text.com/read/article-5/">[S8]</a>. That rule applies to archives too.</p><p>- Give the retention job its own limited account. A job that can drop partitions can drop your data. Use a dedicated maintenance role that can only do that one job, and log what it drops. This is engineering judgment, not a quote from a manual.</p><p>- Back up and test a restore before you drop. Dropping a partition is quick, and that is the danger. PostgreSQL’s design lets you back up or process the detached table before dropping it <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a>. Use that window, and prove you can restore.</p><p>- Do not count on instant deletion. If a rule says data must be gone by a certain moment, remember MongoDB’s TTL does not guarantee immediate deletion <a href="https://mongodb.com/docs/manual/core/index-ttl/">[S6]</a> <a href="https://mongodb.com/docs/rapid/core/timeseries/timeseries-automatic-removal">[S7]</a>. Also remember that deleted rows can still exist in backups and on replicas. In a previous post <a href="https://medium.com/@dtechwizard/read-replicas-make-your-database-faster-to-ask-not-faster-to-be-right-95ef3e1eb117">here</a>, we saw that a replica applies changes a little later, so a deletion is not finished until every copy agrees. That is judgment, not a documented guarantee.</p><p>- Check what the partition key reveals. If you partition by customer or region, the partition names can leak facts about your business to anyone who can list tables. This one is judgment too.</p><p>- Watch the uniqueness gap. A weaker uniqueness guarantee <a href="https://postgresql.org/docs/current/ddl-partitioning.html">[S1]</a> is also a security matter when it protects payments or one-time tokens. Re-check any rule that used to rely on a single unique column.</p><p>Three questions before you partition:<br>1. Do most of my queries filter on one key, such as a date?<br>2. How long must each kind of data stay, and who decided?<br>3. Who can open the store room, and who proves the drawers are really gone?</p><p>Decide the retention rule before you choose the partition key.</p><p>Sources<br>S1: PostgreSQL manual, Table Partitioning. <a href="https://postgresql.org/docs/current/ddl-partitioning.html">https://postgresql.org/docs/current/ddl-partitioning.html</a><br>S2: Microsoft Learn, Partitioned tables and indexes. <a href="https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes">https://learn.microsoft.com/sql/relational-databases/partitions/partitioned-tables-and-indexes</a><br>S3: Oracle Database 19c VLDB and Partitioning Guide, Partitioning Concepts. <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html">https://docs.oracle.com/en/database/oracle/oracle-database/19/vldbg/partition-concepts.html</a><br>S4: Oracle Database 21c VLDB and Partitioning Guide, Recommendations for Choosing a Partitioning Strategy. <a href="https://docs.oracle.com/en/database/oracle/oracle-database/21/vldbg/recommendations-partition-strategy.html">https://docs.oracle.com/en/database/oracle/oracle-database/21/vldbg/recommendations-partition-strategy.html</a><br>S5: Oracle Database 11g Release 2 Licensing Information, Feature availability by edition (older page). <a href="https://docs.oracle.com/cd/E17116_01/doc/license.112/e10594/editions.htm">https://docs.oracle.com/cd/E17116_01/doc/license.112/e10594/editions.htm</a><br>S6: MongoDB manual, TTL Indexes. <a href="https://mongodb.com/docs/manual/core/index-ttl/">https://mongodb.com/docs/manual/core/index-ttl/</a><br>S7: MongoDB manual, Automatic Removal for Time Series Collections. <a href="https://mongodb.com/docs/rapid/core/timeseries/timeseries-automatic-removal">https://mongodb.com/docs/rapid/core/timeseries/timeseries-automatic-removal</a><br>S8: GDPR, Article 5, principles relating to processing of personal data (consolidated text). <a href="https://gdpr-text.com/read/article-5/">https://gdpr-text.com/read/article-5/</a><br>S9: KPMG Nigeria, The Nigeria Data Protection Act, 2023 (commentary). <a href="https://assets.kpmg.com/content/dam/kpmg/ng/pdf/the-nigeria-data-protection-act-2023.pdf">https://assets.kpmg.com/content/dam/kpmg/ng/pdf/the-nigeria-data-protection-act-2023.pdf</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f06121702bbc" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Read Replicas Make Your Database Faster to Ask, Not Faster to Be Right]]></title>
            <link>https://dtechwizard.medium.com/read-replicas-make-your-database-faster-to-ask-not-faster-to-be-right-95ef3e1eb117?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/95ef3e1eb117</guid>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Tue, 03 Nov 2026 09:21:04 GMT</pubDate>
            <atom:updated>2026-11-03T09:21:04.744Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Bj8lnd595jd-tRFlefHjcQ.jpeg" /></figure><p>Imagine a company with one head office and ten branch shops. The head office keeps the official price list. Every branch holds a copy, delivered by courier. Customers walk into the branches, so the head office is never swamped, and everyone is happy.</p><p>Then, at 9:00, a price changes. The head office updates the list at once. The courier is still on the road. For the next few minutes, one branch quotes the old price to anyone who asks. Nothing is broken. The system is doing exactly what it was built to do. It is just not instant.</p><p>That is a read replica. It is a second database that receives every change from the main one, the primary, and answers read-only questions. Your reads spread across many machines, and the primary gets room to breathe. The price is that a replica is always a little behind, and sometimes a lot behind.</p><p>In a previous post <a href="https://medium.com/@dtechwizard/the-two-book-rule-of-data-modeling-one-book-for-the-truth-one-for-the-crowd-46292c4e3b13">here</a>, we made copies of data inside one database, and saw that every copy needs someone to keep it fresh. A replica is the same idea at the level of a whole database, and the same bill arrives.</p><p>What the manuals admit about lag<br>PostgreSQL’s manual says it directly: the data on the standby takes some time to arrive from the primary, so there will be a measurable delay between primary and standby <a href="https://postgresql.org/docs/current/hot-standby.html">[S1]</a>. Its standby is strictly read-only, and not even temporary tables may be written there <a href="https://postgresql.org/docs/current/hot-standby.html">[S1]</a>.</p><p>PostgreSQL gives you three clocks for measuring this. On the primary, the view pg_stat_replication shows write_lag, flush_lag and replay_lag for each standby <a href="https://www.postgresql.org/docs/17/monitoring-stats.html">[S2]</a>. They measure the time between flushing recent WAL on the primary (WAL is the database’s diary of changes) and being told the standby has written it, flushed it, or fully applied it <a href="https://www.postgresql.org/docs/17/monitoring-stats.html">[S2]</a>. Only replay_lag tells you when a reader on the standby could actually see the change <a href="https://www.postgresql.org/docs/17/monitoring-stats.html">[S2]</a>. Two cautions from the same page: these are not predictions of how long catching up will take, and on a fully caught up, idle system the columns return to NULL after a short time, so your monitoring must decide whether NULL means zero or missing <a href="https://www.postgresql.org/docs/17/monitoring-stats.html">[S2]</a>:</p><p><em>SELECT application_name, sync_state, write_lag, flush_lag, replay_lag<br>FROM pg_stat_replication;</em></p><p>Microsoft says the same thing about SQL Server’s readable secondary replicas: there is latency, usually only a matter of seconds, but in unusual cases, such as network issues reducing throughput, it can become significant, and it rises when I/O bottlenecks occur or data movement is suspended <a href="https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups">[S5]</a>. The guidance is blunt: use secondaries if your read workloads can tolerate some latency, and where latency is unacceptable, run them against the primary <a href="https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups">[S5]</a>.</p><p>Oracle can open a standby for queries while it keeps applying changes, a feature called real-time query, and it requires the Oracle Active Data Guard option licence <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sbydb/managing-oracle-data-guard-physical-standby-databases.html">[S8]</a>. Apply lag is defined as the time between when a change became visible on the standby and when it was first visible on the primary, and you can watch it in V$DATAGUARD_STATS <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sbydb/managing-oracle-data-guard-physical-standby-databases.html">[S8]</a>.</p><p>MongoDB is the most direct of all. Its manual says all read preference modes except primary may return stale data, because secondaries replicate from the primary asynchronously, and you should make sure your application can tolerate that <a href="https://www.mongodb.com/docs/manual/core/read-preference/">[S10]</a>. Also, the default mode is primary, so replica reads in MongoDB are something you opt into <a href="https://www.mongodb.com/docs/manual/core/read-preference/">[S10]</a>.</p><p>The nastiest effect of lag is not a stale dashboard. It is a user who changes something and is immediately shown the old value. A customer saves a new address, the next page loads from a replica that has not caught up, and the old address stares back. They save again. They contact support.<br>There are three honest ways out:<br>First, send that user’s reads to the primary for a short window after they write. It is simple and works on every database. In a previous post <a href="https://medium.com/@dtechwizard/the-hidden-numbers-that-decide-how-many-users-you-can-serve-connection-pools-worker-limits-and-0647f8afe505">here</a>, we looked at connection pools, and a separate pool for the primary and for the replicas is a natural place to make this choice.</p><p>Second, make the write wait. PostgreSQL’s synchronous_commit setting accepts remote_apply, which makes a commit wait until the standby has replayed it, so queries on that standby can see it <a href="https://postgresql.org/docs/current/runtime-config-wal.html">[S3]</a>. The manual warns this causes much larger commit delays than the other settings, because it waits for replay <a href="https://postgresql.org/docs/current/runtime-config-wal.html">[S3]</a>. It only has an effect when synchronous standbys are configured <a href="https://postgresql.org/docs/current/runtime-config-wal.html">[S3]</a>.</p><p>Third, use the database’s own consistency tools. MongoDB’s manual notes that causal consistency guarantees with majority read and write concern hold across all members, and that read preference does not change that <a href="https://www.mongodb.com/docs/manual/core/read-preference/">[S10]</a>. Reads inside a transaction must use the primary <a href="https://www.mongodb.com/docs/manual/core/read-preference/">[S10]</a>.</p><p>Set a limit on staleness<br>Most of the time you do not need a replica to be perfect. You need it not to be badly behind. Each database lets you say so.<br>In Oracle, a session can set a tolerance. A nonzero value STANDBY_MAX_DATA_DELAY lets queries run only if apply lag is at or below it, and otherwise returns error ORA-3172 <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sbydb/managing-oracle-data-guard-physical-standby-databases.html">[S8]</a>. Setting it to 0 asks for exactly the primary’s answer, and it also needs synchronous transport, real-time apply, and a synchronized status <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sbydb/managing-oracle-data-guard-physical-standby-databases.html">[S8]</a>. Your code catches ORA-3172 and retries on the primary:</p><p><em>ALTER SESSION SET STANDBY_MAX_DATA_DELAY = 2;</em></p><p>In MongoDB, maxStalenessSeconds tells the driver to stop using any secondary whose estimated lag is above the value <a href="https://www.mongodb.com/docs/manual/core/read-preference-staleness/">[S11]</a>. The minimum is 90 seconds, because the estimate is coarse and based on periodic checks, and by default there is no maximum at all <a href="https://www.mongodb.com/docs/manual/core/read-preference-staleness/">[S11]</a>:</p><p><em>mongodb://host1,host2,host3/?replicaSet=rs0&amp;readPreference=secondaryPreferred&amp;maxStalenessSeconds=120</em></p><p>In SQL Server, an application opts in by declaring a read-intent connection, and read-only routing through the availability group listener sends it to a readable secondary <a href="https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups">[S5]</a>. Microsoft notes that read-intent requests can be load balanced across replicas, and that all queries on a secondary are mapped to snapshot isolation with locking hints ignored<a href="https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups"> [S5]</a>:</p><p><em>Server=ag-listener;Database=shop;ApplicationIntent=ReadOnly;</em></p><p>In PostgreSQL, the manual does not give a built-in per-query lag limit on the page I used. The usual answer is to watch replay_lag and route traffic away from a replica that falls behind your limit<a href="https://www.postgresql.org/docs/17/monitoring-stats.html"> [S2]</a>.</p><p>How a replica can hurt the primary<br>This is the part that surprises people, because the replica is supposed to be the safe place to run heavy queries.<br>In PostgreSQL, the primary does not know what queries run on a standby, so it will not wait for them <a href="https://postgresql.org/docs/current/hot-standby.html">[S1]</a>. When a standby query conflicts with incoming changes and the delay limit passes, the query is cancelled, which usually shows up as just a cancellation error <a href="https://postgresql.org/docs/current/hot-standby.html">[S1]</a>. The fix many teams reach for is hot_standby_feedback, which stops VACUUM from removing recently dead rows so cleanup conflicts do not occur, but it delays cleanup on the primary and may cause undesirable table bloat <a href="https://postgresql.org/docs/current/hot-standby.html">[S1]</a>.</p><p>SQL Server has two versions of this. Because secondary reads use row versioning, the primary adds 14 bytes of overhead to changed rows when read access is enabled, which can cause page splits, and tempdb on the secondary needs space<a href="https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups"> [S5]</a>. And read workloads on a secondary can block DDL changes being applied through the log, and the cleanup of ghost records on the primary can be blocked by transactions on a secondary, which also prevents log truncation <a href="https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups">[S5]</a>.</p><p>The tradeoff<br>A read replica gives you more read capacity, a place to run reports away from the primary, and in most setups a warm copy for failover. It costs you in these ways:<br>- Answers are sometimes old, so every read needs an opinion about how old is acceptable.<br>- A user’s own writes may not be visible right away, so you need a routing rule.<br>- Waiting for the replica on every commit makes writes slower, and not waiting means you accept lag <a href="https://postgresql.org/docs/current/runtime-config-wal.html">[S3]</a>.</p><p>A replica adds work for the primary and for your team. The primary may hold more history, as PostgreSQL’s feedback setting shows <a href="https://postgresql.org/docs/current/hot-standby.html">[S1]</a>, and someone has to watch the lag, the disk, and the connection to each replica.</p><p>A replica multiplies reads, not writes. Every replica applies the same stream of writes as the primary, so a write-heavy system does not get faster by adding more of them. That last point is engineering judgment, not a quote from a manual.</p><p>My rule is to try the cheapest fixes first: an index from a previous post <a href="https://medium.com/@dtechwizard/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b">here</a>, a query plan check from a previous post <a href="https://medium.com/@dtechwizard/your-database-will-tell-you-exactly-why-it-is-slow-most-people-never-ask-2b383db8da70">here</a>, and a cache from a previous post <a href="https://medium.com/@dtechwizard/the-fastest-request-is-the-one-that-never-happens-a-practical-guide-to-caching-from-one-user-to-c4ed048167bf">here</a>. Add a replica when reads are the real bottleneck and some of them can honestly be a few seconds old.</p><p>The security angle<br>A replica is not a lesser copy. It holds the same data as the primary, so anything that protects the primary has to protect it too. Here is what I check.</p><p>Treat the replica as production data. PostgreSQL’s manual stresses that only trusted users should be able to read the WAL stream, because it is easy to extract privileged information from it <a href="https://postgresql.org/docs/current/warm-standby.html">[S4]</a>. The same logic covers the replica itself: encryption at rest, network rules, audit logging, and patching should match the primary. That is judgment, but it follows from the fact that the replica holds the same rows.</p><p>Give the replication link its own account, with only that right. PostgreSQL’s manual says the standby must authenticate as an account with the REPLICATION privilege, recommends a dedicated account with REPLICATION and LOGIN, and notes that this privilege does not let the user modify data on the primary <a href="https://postgresql.org/docs/current/warm-standby.html">[S4]</a>. Access is controlled by a pg_hba.conf record that uses the special word replication in the database field, and the manual’s example limits it to one address with scram-sha-256 <a href="https://postgresql.org/docs/current/warm-standby.html">[S4]</a>:</p><p><em>host replication foo 192.168.1.100/32 scram-sha-256</em></p><p>Authenticate and encrypt the link between nodes. Each database has its own way:<br>SQL Server configures authentication and encryption on the database mirroring endpoint used by availability groups <a href="https://learn.microsoft.com/en-us/sql/database-engine/database-mirroring/the-database-mirroring-endpoint-sql-server?view=sql-server-2017">[S6]</a>. Windows Authentication is recommended when every instance runs under a domain account, and certificates are required when a built-in or non-domain account is used <a href="https://learn.microsoft.com/en-us/sql/database-engine/database-mirroring/the-database-mirroring-endpoint-sql-server?view=sql-server-2017">[S6]</a>. An older Microsoft page, for SQL Server 2012, says encryption is required by default, recommends requiring it unless you can guarantee the network is secure, and recommends AES over RC4, which it calls deprecated <a href="https://learn.microsoft.com/en-us/previous-versions/sql/sql-server-2012/ms186360(v=sql.110)">[S7]</a>. That page is old, so check the current default on your version.</p><p>Oracle sends redo over Oracle Net sessions <a href="https://docs.oracle.com/en/database/oracle/oracle-database/21/sbydb/oracle-data-guard-redo-transport-services.html">[S9]</a>. SSL can be used for redo transport authentication when a set of conditions is met, including Oracle Internet Directory, wallets, and certificates. Otherwise, each database uses a remote login password file, and the password of the authenticating user, SYS by default, must match at both ends <a href="https://docs.oracle.com/en/database/oracle/oracle-database/21/sbydb/oracle-data-guard-redo-transport-services.html">[S9]</a>. That page does not state that redo is encrypted in transit, so confirm network encryption separately.</p><p>MongoDB members can authenticate each other with keyfiles or X.509 certificates, and enabling internal authentication also enables client authorization <a href="https://www.mongodb.com/docs/manual/core/security-internal-authentication/">[S12]</a>. A keyfile must be 6 to 1024 characters of base64 <a href="https://www.mongodb.com/docs/manual/core/security-internal-authentication/">[S12]</a>.</p><p>For PostgreSQL, the streaming replication page I read does not mention encrypting the connection, and its sample connection string even contains a plaintext password <a href="https://postgresql.org/docs/current/warm-standby.html">[S4]</a>. Look up the TLS options for replication connections in the PostgreSQL documentation for your version before you decide.</p><p>Never let a lagging replica make an authorization decision. If you revoke a user’s access, disable an account, or invalidate a session, the change reaches the replica a little later. For that window, a replica can still show the user as active. So permission checks, token and session revocation, balances, stock counts, and anything where being wrong has a security or money cost should read from the primary. SQL Server’s guidance to use the primary where latency is unacceptable <a href="https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups">[S5]</a> and MongoDB’s rule that transactions read from the primary <a href="https://www.mongodb.com/docs/manual/core/read-preference/">[S10]</a> point the same way. The specific revocation example is my judgment, derived from the documented lag.</p><p>Remember who reads from the replica. Reporting tools and analysts often get replica access because it feels safe. It is read-only, but reading is exactly how data leaks. Give those accounts the least access they need, and do not hand out the replica’s admin rights.</p><p>Deletion is not finished until the replicas agree. If you delete a customer’s record on the primary, a lagging replica still holds it for a while, and so do your backups. For privacy and retention commitments, make sure your process counts replicas and backups, not only the primary. This is judgment, not a quote.</p><p>A stuck replica can take the primary down. PostgreSQL replication slots keep WAL until every standby has received it, and the manual warns they can retain so many segments that they fill the space allocated for pg_wal, with max_slot_wal_keep_size as the limiting setting <a href="https://postgresql.org/docs/current/warm-standby.html">[S4]</a>. A forgotten or broken replica is therefore an availability risk, not just a monitoring gap.</p><p>Three questions before you add one<br>1. Which reads can honestly be a few seconds old, and which cannot?<br>2. What does a user see right after their own write?<br>3. Who watches the lag, and what do we do when it passes our limit?<br>Write the answers down, and give every read a freshness label before you choose where it runs.</p><p>Sources<br>S1: PostgreSQL manual, Hot Standby. <a href="https://postgresql.org/docs/current/hot-standby.html">https://postgresql.org/docs/current/hot-standby.html</a><br>S2: PostgreSQL 17 manual, Cumulative Statistics System (pg_stat_replication). <a href="https://www.postgresql.org/docs/17/monitoring-stats.html">https://www.postgresql.org/docs/17/monitoring-stats.html</a><br>S3: PostgreSQL manual, Write Ahead Log settings (synchronous_commit). <a href="https://postgresql.org/docs/current/runtime-config-wal.html">https://postgresql.org/docs/current/runtime-config-wal.html</a><br>S4: PostgreSQL manual, Log-Shipping Standby Servers (streaming replication). <a href="https://postgresql.org/docs/current/warm-standby.html">https://postgresql.org/docs/current/warm-standby.html</a><br>S5: Microsoft Learn, Readable secondary replicas in Always On availability groups. <a href="https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups">https://learn.microsoft.com/sql/database-engine/availability-groups/windows/active-secondaries-readable-secondary-replicas-always-on-availability-groups</a><br>S6: Microsoft Learn, The database mirroring endpoint. <a href="https://learn.microsoft.com/en-us/sql/database-engine/database-mirroring/the-database-mirroring-endpoint-sql-server?view=sql-server-2017">https://learn.microsoft.com/en-us/sql/database-engine/database-mirroring/the-database-mirroring-endpoint-sql-server?view=sql-server-2017</a><br>S7: Microsoft Learn (SQL Server 2012 archive), Transport Security for Database Mirroring and AlwaysOn Availability Groups. <a href="https://learn.microsoft.com/en-us/previous-versions/sql/sql-server-2012/ms186360(v=sql.110)">https://learn.microsoft.com/en-us/previous-versions/sql/sql-server-2012/ms186360(v=sql.110)</a><br>S8: Oracle Database 19c Data Guard Concepts and Administration, Managing Physical and Snapshot Standby Databases. <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sbydb/managing-oracle-data-guard-physical-standby-databases.html">https://docs.oracle.com/en/database/oracle/oracle-database/19/sbydb/managing-oracle-data-guard-physical-standby-databases.html</a><br>S9: Oracle Database 21c Data Guard Concepts and Administration, Redo Transport Services. <a href="https://docs.oracle.com/en/database/oracle/oracle-database/21/sbydb/oracle-data-guard-redo-transport-services.html">https://docs.oracle.com/en/database/oracle/oracle-database/21/sbydb/oracle-data-guard-redo-transport-services.html</a><br>S10: MongoDB manual, Read Preference. <a href="https://www.mongodb.com/docs/manual/core/read-preference/">https://www.mongodb.com/docs/manual/core/read-preference/</a><br>S11: MongoDB manual, Read Preference maxStalenessSeconds. <a href="https://www.mongodb.com/docs/manual/core/read-preference-staleness/">https://www.mongodb.com/docs/manual/core/read-preference-staleness/</a><br>S12: MongoDB manual, Internal/Membership Authentication. <a href="https://www.mongodb.com/docs/manual/core/security-internal-authentication/">https://www.mongodb.com/docs/manual/core/security-internal-authentication/</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=95ef3e1eb117" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Two-Book Rule of Data Modeling: One Book for the Truth, One for the Crowd]]></title>
            <link>https://dtechwizard.medium.com/the-two-book-rule-of-data-modeling-one-book-for-the-truth-one-for-the-crowd-46292c4e3b13?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/46292c4e3b13</guid>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[mongodb]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 03:44:33 GMT</pubDate>
            <atom:updated>2026-11-02T03:44:33.451Z</atom:updated>
            <content:encoded><![CDATA[<p>A shopkeeper keeps a ledger. Every sale goes in once, in order, with the amount, the item, and the customer. It is the most honest book in the shop. If a price was wrong, she fixes it in one place. Nothing in the book contradicts anything else.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8qjEnLfREDQAxy9icn9MYQ.jpeg" /></figure><p>Then business grows, and every day a hundred people ask her the same question: “How much did we sell today?” Each time, she must run her finger down the whole ledger and add. At first it takes a minute. Later it takes an hour—customers queue. The ledger is not wrong. It is simply not built for being asked the same question a hundred times.</p><p>So she does what any sensible shopkeeper would do. She hangs a board by the door: “Today’s total: 84,300.” Now the hundred people read the board and leave in two seconds. Her day is saved.</p><p>But the board has a price. Every sale now needs two writes, one in the ledger and one on the board. If she is busy and forgets, the board lies, and every customer who trusts it is misled. And the board only answers one question. Ask for sales by item, and she is back to the ledger.</p><p>That is data modeling for reads versus writes. The ledger and the board are the same data in two shapes, and every database design is a decision about how many boards you can afford to keep up to date.</p><p>In a previous post <a href="https://medium.com/@dtechwizard/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b">here</a> we saw one kind of board: the index, a cabinet of cards that points at the data. Today we go one level up, to whole copies of data shaped for the people reading them.</p><p>Book one: keep the truth in one place<br>Database designers call this normalizing. Each fact is stored exactly once. A customer’s address lives in one row in one table, and every order points to that customer. When she moves house, you change one row, and every order now shows the new address. Nothing can disagree with itself, because there is only one copy to disagree with. This is the right default for anything that gets written to. It makes writes simple and keeps your data honest.</p><p>The price is paid at read time. To show an order page, you might need the order, the customer, the items, the product names, and the shipping address, which means joining five tables. For one page, that is fine. For ten thousand people looking at dashboards every minute, it adds up. MongoDB’s documentation makes the same point from the other side: embedded data models are often considered denormalized because frequently accessed data is duplicated, and that duplication is the trade you accept for reading it in one go <a href="https://www.mongodb.com/docs/manual/data-modeling/embedding/">[S5]</a>.</p><p>Book two: a copy shaped for the readers<br>The first tool most relational databases give you is the materialized view. A normal view is just a saved query, run again every time. A materialized view stores the answer. PostgreSQL’s manual describes it as persisting the results in a table-like form, so that reading it is like reading from a table <a href="https://www.postgresql.org/docs/current/rules-materializedviews.html">[S1]</a>.</p><p>The manual is refreshingly honest about the cost: while access to the data stored in a materialized view is often much faster than accessing the underlying tables directly, the data is not always current, yet sometimes current data is not needed <a href="https://www.postgresql.org/docs/current/rules-materializedviews.html">[S1]</a>. Its own example is a sales summary for a dashboard that graphs historical sales, refreshed nightly <a href="https://www.postgresql.org/docs/current/rules-materializedviews.html">[S1]</a>. The dashboard does not care about the last five minutes. It cares about being fast.</p><p>There is even a striking number in the manual. In a separate example that loads a word list from a file into a materialized view with indexes, a spell-check lookup that took about 188ms against the original source took about 0.12ms against the materialized view <a href="https://www.postgresql.org/docs/current/rules-materializedviews.html">[S1]</a>. That example is about a foreign data source, not an ordinary table, so do not expect those exact numbers from your own tables. The lesson is the size of the gap when you stop recomputing the same answer.</p><p>Here is the shopkeeper’s board, in all three relational databases. We will use the same dashboard question: sales per seller per day.</p><p>PostgreSQL keeps the copy until you refresh it:</p><p>CREATE MATERIALIZED VIEW daily_sales AS<br>SELECT seller_id, order_date,<br> SUM(total) AS sales_total, COUNT(*) AS order_count<br>FROM orders<br>WHERE order_date &lt; CURRENT_DATE<br>GROUP BY seller_id, order_date;</p><p>CREATE UNIQUE INDEX daily_sales_key ON daily_sales (seller_id, order_date);</p><p>-run from a nightly job<br>REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales;</p><p>A few details from the manual are worth knowing. A refresh completely replaces the contents of the view <a href="https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html">[S2]</a>. Without CONCURRENTLY, a refresh that touches many rows may block people reading the view, though it tends to use fewer resources and finish faster <a href="https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html">[S2]</a>. With CONCURRENTLY, readers are not locked out, but it is only allowed when the view has at least one UNIQUE index made of plain column names that covers all rows, which is why the example creates one <a href="https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html">[S2]</a>. And only one refresh at a time may run against a view <a href="https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html">[S2]</a>.</p><p>SQL Server calls its version an indexed view. The result is stored by putting a unique clustered index on the view, and the first index on a view must be that unique clustered one <a href="https://learn.microsoft.com/en-us/sql/relational-databases/views/create-indexed-views?view=sql-server-ver17">[S3]</a>. The view must be created WITH SCHEMABINDING, and when it groups, it must include COUNT_BIG(*) <a href="https://learn.microsoft.com/en-us/sql/relational-databases/views/create-indexed-views?view=sql-server-ver17">[S3]</a>:</p><p>CREATE VIEW dbo.daily_sales<br>WITH SCHEMABINDING<br>AS<br>SELECT seller_id, order_date,<br> SUM(total) AS sales_total,<br> COUNT_BIG(*) AS order_count<br>FROM dbo.orders<br>GROUP BY seller_id, order_date;<br>GO</p><p>CREATE UNIQUE CLUSTERED INDEX ix_daily_sales<br>ON dbo.daily_sales (seller_id, order_date);</p><p>Microsoft lists more rules for indexed views, so read the full list before you build one <a href="https://learn.microsoft.com/en-us/sql/relational-databases/views/create-indexed-views?view=sql-server-ver17">[S3]</a>. There is a bonus: once it exists, the optimizer can use it to speed up matching queries even when the query does not mention the view <a href="https://learn.microsoft.com/en-us/sql/relational-databases/views/create-indexed-views?view=sql-server-ver17">[S3]</a>. The bill is on the write side. Microsoft warns that when a table referenced by many indexed views, or by a few complex ones, takes an INSERT, UPDATE or DELETE, those views must be updated too, and DML performance can degrade significantly <a href="https://learn.microsoft.com/en-us/sql/relational-databases/views/create-indexed-views?view=sql-server-ver17">[S3]</a>. This is the shopkeeper updating a very busy board on every sale.</p><p>Oracle gives you a choice of how the board is kept fresh. A materialized view stores precomputed results of expensive joins and aggregations <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/dwhsg/basic-materialized-views.html">[S4]</a>. It can refresh ON DEMAND (when someone runs a refresh procedure, the default) or ON COMMIT, which refreshes automatically when a transaction that changed a base table commits <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/dwhsg/basic-materialized-views.html">[S4]</a>. Oracle notes that ON COMMIT makes the commit take slightly longer, so it may not suit heavily concurrent workloads<a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/dwhsg/basic-materialized-views.html"> [S4]</a>. A fast refresh, which applies only the changes, normally needs a materialized view log on the base table, and COUNT(*) must be present in the view for all types of fast refresh <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/dwhsg/basic-materialized-views.html">[S4]</a>. The shape below follows Oracle’s own example <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/dwhsg/basic-materialized-views.html">[S4]</a>:</p><p>CREATE MATERIALIZED VIEW LOG ON orders<br>WITH SEQUENCE, ROWID (seller_id, order_date, total)<br>INCLUDING NEW VALUES;</p><p>CREATE MATERIALIZED VIEW daily_sales<br>REFRESH FAST ON COMMIT AS<br>SELECT seller_id, order_date,<br> COUNT(*) AS order_count,<br> SUM(total) AS sales_total,<br> COUNT(total) AS total_count<br>FROM orders<br>GROUP BY seller_id, order_date;</p><p>Oracle also has a warning for the careless: if a view fails during an ON COMMIT refresh, it stops refreshing automatically until you invoke a refresh yourself after fixing the error <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/dwhsg/basic-materialized-views.html">[S4]</a>. That is a board that quietly stops being updated, which is the nightmare version of the shopkeeper forgetting.</p><p>The simplest board of all: a counter<br>Not every board needs a fancy feature. Sometimes the board is one number. Keep an order_count column on the customer row, and change it in the same transaction that inserts the order. It is fast to read and cheap to keep, as long as every code path that creates or cancels an order remembers to update it. The risk is exactly the shopkeeper’s: one forgotten path and the number drifts. If you do this, put the update next to the insert, in one transaction, and add a nightly job that recounts and compares.</p><p>MongoDB: embed or reference<br>MongoDB does not have joins in the same everyday way, so the same decision shows up as a modeling choice. Embedding puts related data inside the parent document. According to MongoDB’s documentation, it gives better read performance, the ability to retrieve related data in a single operation, and the ability to update related data in a single atomic write <a href="https://www.mongodb.com/docs/manual/data-modeling/embedding/">[S5]</a>. It suits one-to-many relationships where the many are viewed in the context of the one <a href="https://www.mongodb.com/docs/manual/data-modeling/embedding/">[S5]</a>.</p><p>The limits are firm. Documents in MongoDB must be smaller than 16 mebibytes <a href="https://www.mongodb.com/docs/manual/data-modeling/embedding/">[S5]</a>. And the documentation names a classic mistake, the unbounded array: if you do not limit how many elements an array can hold, your documents may exceed the 16 MB limit, and an unbounded array can strain application resources and decrease index performance <a href="https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays">[S6]</a>. A book that embeds every review it will ever receive is the textbook case <a href="https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays">[S6]</a>.</p><p>The documented fixes are to use subsetting and referencing to bound arrays <a href="https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays">[S6]</a>. Referencing means each review lives in its own collection, and the book only holds IDs. It solves the size problem but adds latency, because you must query the reviews collection to read them <a href="https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays">[S6]</a>. The subset pattern splits the difference: keep a few recent reviews inside the book for instant display, and the rest in their own collection <a href="https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays">[S6]</a>.</p><p>// books: only the 3 newest reviews live inside the document<br>{<br> title: “Example Book”,<br> recent_reviews: [<br> { reviewer: “Ada”, rating: 5, text: “…” },<br> { reviewer: “Tunde”, rating: 4, text: “…” },<br> { reviewer: “Chidi”, rating: 4, text: “…” }<br> ]<br>}</p><p>// reviews: one document per review, all of them<br>db.reviews.find({ book_id: bookId }).sort({ created_at: -1 }).limit(20)<br>Do not split too far</p><p>Once you taste fast reads, the next temptation is to split the whole system in two: one model for writing and a completely different model for reading. This pattern has a name, CQRS, short for Command Query Responsibility Segregation. Microsoft’s Azure Architecture Center describes it as separating read and write operations into different data models <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs">[S7]</a>.</p><p>The benefits are real. Reads and writes can scale independently, and the read side can use a schema optimized for queries, avoiding complex joins <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs">[S7]</a>. But there is a cost the article states plainly: when the read and write databases are separated, the read data might not show the most recent changes immediately, which means stale data, and keeping the two in sync is hard <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs">[S7]</a>. Because you usually cannot put a message broker and a database in one distributed transaction, the article recommends the Transactional Outbox pattern and idempotent consumers to mitigate this <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs">[S7]</a>. That is the outbox from a previous post <a href="https://medium.com/@dtechwizard/do-not-make-the-user-wait-a-practical-guide-to-queues-and-background-jobs-and-why-your-job-will-fcf459494bbf">here</a>, and the idempotency idea from a previous post <a href="https://medium.com/@dtechwizard/you-think-your-api-is-slow-because-it-takes-time-to-return-a-response-1e0f428c330f">here</a>. Finally, Microsoft says CQRS might not be suitable when the domain or business rules are simple, or when simple CRUD-style access is enough <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs">[S7]</a>.</p><p>My own rule: try the cheapest board first. An index, then a counter, then a materialized view, then a separate read model. Each step is faster to read and more expensive to keep honest.</p><p>Three questions before you copy anything<br>How often is it read compared with written? A copy pays off when reads far outnumber writes. If the data changes on every request, the board will cost more than the ledger saves.<br>How stale can it be? Nightly, every minute, or instantly? The answer decides your refresh style. If a stale number could cause real harm, such as a balance, keep reading from the truth.<br>How big can the list grow? If the answer is “no limit”, it should not be embedded in one document or one row.</p><p>Then write down who keeps each copy fresh. A board without an owner is a board that lies.</p><p>The common thread<br>Databases are not slow because of bad luck. They are slow because the same question was asked a million times and answered from scratch each time. The fix is not magic. It is a second book, kept honest, shaped for the crowd. In a previous post <a href="https://medium.com/@dtechwizard/the-shortcut-that-makes-your-app-fast-and-the-day-it-breaks-a-practical-guide-to-backend-caching-8a37291efaed">here</a>, we saw what happens when a shortcut like a cache hides a slow database until the day it breaks. A precomputed copy is the same trade done on purpose: know what it costs, know how stale it can be, and know who keeps it fresh.</p><p>A quick audit for your own system: list every copy of data you keep, whether it is a cache, a counter, a summary table, or a view. For each one, write down what refreshes it, how stale it can get, and what happens when the refresh quietly fails.</p><p>Sources<br>S1: PostgreSQL manual, Materialized Views. <a href="https://www.postgresql.org/docs/current/rules-materializedviews.html">https://www.postgresql.org/docs/current/rules-materializedviews.html</a><br>S2: PostgreSQL manual, REFRESH MATERIALIZED VIEW. <a href="https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html">https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html</a><br>S3: Microsoft Learn, Create Indexed Views. <a href="https://learn.microsoft.com/en-us/sql/relational-databases/views/create-indexed-views?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/relational-databases/views/create-indexed-views?view=sql-server-ver17</a><br>S4: Oracle Database Data Warehousing Guide, Basic Materialized Views. <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/dwhsg/basic-materialized-views.html">https://docs.oracle.com/en/database/oracle/oracle-database/19/dwhsg/basic-materialized-views.html</a><br>S5: MongoDB manual, Embedded Data in Your MongoDB Schema. <a href="https://www.mongodb.com/docs/manual/data-modeling/embedding/">https://www.mongodb.com/docs/manual/data-modeling/embedding/</a><br>S6: MongoDB manual, Avoid Unbounded Arrays. <a href="https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays/">https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays/</a><br>S7: Microsoft Azure Architecture Center, CQRS Pattern. <a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs">https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=46292c4e3b13" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Your Database Will Tell You Exactly Why It Is Slow. Most People Never Ask.]]></title>
            <link>https://dtechwizard.medium.com/your-database-will-tell-you-exactly-why-it-is-slow-most-people-never-ask-2b383db8da70?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/2b383db8da70</guid>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[database]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Sat, 31 Oct 2026 08:13:26 GMT</pubDate>
            <atom:updated>2026-10-31T08:13:26.785Z</atom:updated>
            <content:encoded><![CDATA[<p>Anyone who has used a map app in a busy city knows two numbers. One is the time it promised when you set off. The other is the time the trip really took. When those two numbers are close, the app understood the road. When they are miles apart, the app had the wrong picture, and every decision it made on the way was based on that wrong picture.</p><p>Databases work in the same way.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HczgjZxucfIS_7Rxbyr08A.jpeg" /></figure><p>When you send a query, the database does not just run it. First it plans: which table to read first, whether to use an index, how to join things together. That plan is a route, and the database picks it using a set of guesses about how much data is where. Then it follows the route. If the guesses were good, the query is quick. If they were bad, you get the database equivalent of a taxi taking the long way round.</p><p>The good news is that every serious database will show you the route, and most will also show you how the trip actually went. Almost nobody looks. They add an index by instinct, or throw more hardware at the problem, or blame the network. Today is about reading the plan, which is the single most useful skill in database performance work.</p><p>In a previous post <a href="https://medium.com/@dtechwizard/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b">here</a>, we looked at indexes, the cabinet of cards that lets a database go straight to what it needs. A query plan is how you prove the database is actually using that cabinet.</p><p>1. Ask for the plan<br>Each database has its own way of asking. Here is the same question from our previous post <a href="https://medium.com/@dtechwizard/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b">here</a> (this customer’s open orders, newest 20) in all four.</p><p>PostgreSQL uses the EXPLAIN command <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a><a href="https://www.postgresql.org/docs/current/sql-explain.html">[S2]</a>:<br><em>EXPLAIN (ANALYZE, BUFFERS)<br>SELECT id, created_at, total<br>FROM orders<br>WHERE customer_id = 42 AND status = ‘open’<br>ORDER BY created_at DESC<br>LIMIT 20;</em></p><p>SQL Server shows plans in SQL Server Management Studio. Choose Include Actual Execution Plan on the Query menu and run the query <a href="https://learn.microsoft.com/en-us/sql/relational-databases/performance/display-an-actual-execution-plan?view=sql-server-ver17">[S3]</a>. If you prefer code, SET STATISTICS XML ON returns the plan information for each statement after it runs <a href="https://learn.microsoft.com/en-us/sql/relational-databases/performance/display-an-actual-execution-plan?view=sql-server-ver17">[S3]</a>. You need the SHOWPLAN permission on every database the query touches <a href="https://learn.microsoft.com/en-us/sql/relational-databases/performance/display-an-actual-execution-plan?view=sql-server-ver17">[S3]</a>.<br><em>SET STATISTICS IO ON;<br>SELECT TOP (20) id, created_at, total<br>FROM orders<br>WHERE customer_id = 42 AND status = ‘open’<br>ORDER BY created_at DESC;</em></p><p>Oracle gives you two routes. EXPLAIN PLAN followed by DBMS_XPLAN.DISPLAY shows the estimated plan <a href="https://oracle-base.com/articles/9i/dbms_xplan">[S7]</a>. For the plan that really ran, add a GATHER_PLAN_STATISTICS hint to the query and then call DISPLAY_CURSOR <a href="https://oracle-base.com/articles/9i/dbms_xplan">[S7]</a><br><em>SELECT /*+ GATHER_PLAN_STATISTICS */ id, created_at, total<br>FROM orders<br>WHERE customer_id = 42 AND status = ‘open’<br>ORDER BY created_at DESC<br>FETCH FIRST 20 ROWS ONLY;</em></p><p><em>SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format =&gt; ‘ALLSTATS LAST’));</em></p><p>MongoDB has the explain method, and the executionStats mode runs the query and reports what happened <a href="https://www.mongodb.com/docs/manual/tutorial/analyze-query-plan/">[S8]</a><br><em>db.orders.find({ customer_id: 42, status: “open” })<br> .sort({ created_at: -1 })<br> .limit(20)<br> .explain(“executionStats”)</em></p><p>A safety rule that saves careers. In PostgreSQL, plain EXPLAIN only plans. Adding ANALYZE makes the statement actually execute, and the manual is blunt about it: any side effects will happen as usual <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a><a href="https://www.postgresql.org/docs/current/sql-explain.html">[S2]</a>. That is fine for a SELECT. For an INSERT, UPDATE, or DELETE, the manual’s advice is to wrap it so the change is undone <a href="https://www.postgresql.org/docs/current/sql-explain.html">[S2]</a><br><em>BEGIN;<br>EXPLAIN ANALYZE UPDATE orders SET status = ‘closed’ WHERE customer_id = 42;<br>ROLLBACK;</em></p><p>Remember, even a rolled back statement still did the work, so be careful running it against a busy production database.</p><p>2. Spot the scan type<br>Every plan, in every database, has a line that says how the table is read. This is the line that tells you whether your index is being used.</p><p>PostgreSQL’s manual uses an example table to show three ways to read it. A sequential scan reads the whole table, and when a query has no WHERE clause, it is the only sensible choice <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>. A bitmap scan is used for moderately selective conditions <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>. An index scan is used for very selective lookups, such as fetching a single row <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>. The manual shows the difference in the plan lines. These two come from two different queries on the same table, so do not read them as a before and after:</p><p><em>Seq Scan on tenk1 (cost=0.00..445.00 rows=10000 width=244)</em></p><p><em>Index Scan using tenk1_unique1 on tenk1 (cost=0.29..8.30 rows=1 width=244)<br> Index Cond: (unique1 = 42)</em></p><p>SQL Server uses its own names. A Table Scan retrieves all rows from a table without a clustered index, and a Clustered Index Scan reads the whole clustered index <a href="https://learn.microsoft.com/en-us/sql/relational-databases/showplan-logical-and-physical-operators-reference?view=sql-server-ver17">[S5]</a>. An Index Seek uses the seeking ability of an index to process only the rows that satisfy its seek predicate <a href="https://learn.microsoft.com/en-us/sql/relational-databases/showplan-logical-and-physical-operators-reference?view=sql-server-ver17">[S5]</a>. The operator to watch for is the Key Lookup: when the index found the rows but did not hold every column the query needs, SQL Server goes back to the clustered index to fetch the rest, and Microsoft says a Key Lookup with a high number of executions relative to the table size might benefit from a covering index <a href="https://learn.microsoft.com/en-us/sql/relational-databases/showplan-logical-and-physical-operators-reference?view=sql-server-ver17">[S5]</a>. That is the covering index idea from a previous post <a href="https://medium.com/@dtechwizard/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b">here</a>.</p><p>MongoDB reduces it to two words. COLLSCAN means the server scanned the entire collection document by document, which Mongo’s documentation says is generally expensive and can result in slow queries. IXSCAN means an index was used <a href="https://www.mongodb.com/docs/manual/tutorial/analyze-query-plan/">[S8]</a>.</p><p>Here is the best example I know of, taken from MongoDB’s own documentation <a href="https://www.mongodb.com/docs/manual/tutorial/analyze-query-plan/">[S8]</a>. A query for movies released between 2000 and 2005 returns 3,809 documents:<br>With no index, MongoDB examined all 21,349 documents to find those 3,809.<br>After adding an index on year, it examined only 3,809 index keys and 3,809 documents.<br>For a second query that also filtered on the rating (235 matches), a compound index with the rating first meant it examined exactly 235 keys and 235 documents.</p><p>When the number of things examined matches the number returned, the database did no wasted work. That ratio is worth memorising.</p><p>3. Compare the guess with reality<br>Back to the map app. The promised time and the real time are the two numbers that matter, and so it is with plans. A plan shows what the database expected, and the execution shows what it found.</p><p>PostgreSQL’s manual says the thing that’s usually most important to look for is whether the estimated row counts are reasonably close to reality <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>. In the output, the estimate appears as rows inside the first set of brackets, and the real number appears as rows inside the actual section <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>. There is a third number worth understanding, called loops: how many times that step ran. The timings and row counts shown are averages per run, so you multiply by loops to get the true total <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>.</p><p>Oracle makes the comparison explicit. With the statistics hint, the Rows column is replaced by E-Rows (estimated) and A-Rows (actual) side by side <a href="https://oracle-base.com/articles/9i/dbms_xplan">[S7]</a>. SQL Server’s actual plan carries the real row counts, and its Analyze Actual Execution Plan feature exists to make it easier to find scenarios of inaccurate cardinality estimation (that is the word for a wrong row count guess) and to suggest mitigations <a href="https://learn.microsoft.com/en-us/sql/relational-databases/performance/analyze-an-actual-execution-plan?view=sql-server-ver17">[S4]</a>. MongoDB gives you the pair directly: nReturned, and the number of documents and keys examined <a href="https://www.mongodb.com/docs/manual/tutorial/analyze-query-plan/">[S8]</a>.</p><p>What do you do when the guess is far off? <br>The usual cause is stale information. A database makes its estimates from statistics it collected about your data. PostgreSQL’s ANALYZE command refreshes them, and the manual notes that its estimates come from a random sample and can change a little after each ANALYZE <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>. If a table has changed a lot since statistics were last gathered, the database is planning a route using an old map. Refresh the map first, then look at the plan again.</p><p>4. Count the work, not just the seconds<br>Time is a noisy measure. The same query can be fast the second time simply because the data is now in memory. So alongside time, look at how much work was done.</p><p>PostgreSQL’s BUFFERS option reports how many blocks were found already in cache (a hit means a read was avoided because the block was already in memory), and how many had to be read in <a href="https://www.postgresql.org/docs/current/sql-explain.html">[S2]</a>. In current versions, buffer information is included automatically when you use ANALYZE <a href="https://www.postgresql.org/docs/current/sql-explain.html">[S2]</a>. SQL Server’s SET STATISTICS IO reports scan count, logical reads (pages read from the data cache), and physical reads (pages read from disk) <a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/set-statistics-io-transact-sql?view=sql-server-ver17">[S6]</a>. Oracle’s plan with runtime statistics also includes Starts, A-Time, and Buffers columns <a href="https://oracle-base.com/articles/9i/dbms_xplan">[S7]</a>. And MongoDB’s equivalent is the examined versus returned pair from step two <a href="https://www.mongodb.com/docs/manual/tutorial/analyze-query-plan/">[S8]</a>.</p><p>A rule of thumb that works on all four: if the database touches thousands of rows or pages to hand back twenty, something is wrong, and it is usually a missing index, the wrong column order, or a function wrapped around a column, all topics from a previous post <a href="https://medium.com/@dtechwizard/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b">here</a>.</p><p>5. Know the traps<br>Cost is not time. PostgreSQL’s cost numbers are in arbitrary units, with the cost of reading one page sequentially as the baseline of 1.0 <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>. They are for comparing plans, not for predicting seconds. The same manual says the actual time figure, in milliseconds, is a different thing and not comparable to cost units <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>.</p><p>Small tables lie. The manual warns that a plan that looks expensive on a tiny test table may not reflect production behaviour <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>. A table with a hundred rows is often faster to scan than to index, so the database will happily choose the scan. Test on realistic amounts of data.</p><p>LIMIT can make numbers look smaller than estimates. The manual explains that when a query has a LIMIT, the actual row counts and times for some steps can look lower than the estimates, and that this is a display artifact, not an estimation error <a href="https://www.postgresql.org/docs/current/using-explain.html">[S1]</a>.</p><p>A plan shows one query, not the whole page. If a page runs 10,000 small queries, each plan will look fine, and the page will still crawl. That is the N+1 problem from a previous post <a href="https://medium.com/@dtechwizard/the-n-1-query-and-other-silent-killers-why-your-app-is-fast-with-10-records-and-slow-with-10-000-94f4bcabc4cf">here</a>. Plans find slow queries. Logs and traces find slow pages.</p><p>A routine you can repeat<br>1. Pick the slow query and find the exact version your application sends, with real values.<br>2. Get the plan with runtime statistics, using the safety rule if it changes data.<br>3. Check the scan type, then compare estimated against actual rows.<br>4. Check the work done, meaning blocks, reads, or documents examined versus returned.<br>5. Change one thing, such as an index or refreshed statistics, and read the plan again.</p><p>Step five is the one people skip. A plan before and after is the evidence that your change did what you think. Without it, you are guessing, and with the right index in the wrong order, a guess can feel like a fix.</p><p>The common thread<br>Databases are not mysterious. They make a plan, they record how it went, and they will show you both if you ask. The engineers who look fast at a problem are rarely smarter than the rest. They simply read the plan before they start changing things.</p><p>Sources<br>S1: PostgreSQL manual, Using EXPLAIN. <a href="https://www.postgresql.org/docs/current/using-explain.html">https://www.postgresql.org/docs/current/using-explain.html</a><br>S2: PostgreSQL manual, EXPLAIN command reference. <a href="https://www.postgresql.org/docs/current/sql-explain.html">https://www.postgresql.org/docs/current/sql-explain.html</a><br>S3: Microsoft Learn, Display an Actual Execution Plan. <a href="https://learn.microsoft.com/en-us/sql/relational-databases/performance/display-an-actual-execution-plan?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/relational-databases/performance/display-an-actual-execution-plan?view=sql-server-ver17</a><br>S4: Microsoft Learn, Analyze an actual execution plan. <a href="https://learn.microsoft.com/en-us/sql/relational-databases/performance/analyze-an-actual-execution-plan?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/relational-databases/performance/analyze-an-actual-execution-plan?view=sql-server-ver17</a><br>S5: Microsoft Learn, Logical and Physical Showplan Operator Reference. <a href="https://learn.microsoft.com/en-us/sql/relational-databases/showplan-logical-and-physical-operators-reference?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/relational-databases/showplan-logical-and-physical-operators-reference?view=sql-server-ver17</a><br>S6: Microsoft Learn, SET STATISTICS IO. <a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/set-statistics-io-transact-sql?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/t-sql/statements/set-statistics-io-transact-sql?view=sql-server-ver17</a><br>S7: ORACLE-BASE, DBMS_XPLAN: Display Execution Plans. <a href="https://oracle-base.com/articles/9i/dbms_xplan">https://oracle-base.com/articles/9i/dbms_xplan</a><br>S8: MongoDB manual, Interpret Explain Plan Results. <a href="https://www.mongodb.com/docs/manual/tutorial/analyze-query-plan/">https://www.mongodb.com/docs/manual/tutorial/analyze-query-plan/</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2b383db8da70" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Cabinet of Index Cards That Makes a Database Fast and Slow]]></title>
            <link>https://dtechwizard.medium.com/the-cabinet-of-index-cards-that-makes-a-database-fast-and-slow-79434e53666b?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/79434e53666b</guid>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Fri, 30 Oct 2026 01:59:55 GMT</pubDate>
            <atom:updated>2026-10-30T01:59:55.956Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bboME8nGWmma9BWb16hiHw.jpeg" /></figure><p>Picture a school registry holding 2 million exam scripts, filed in the order they arrived. A parent asks for one student’s script. Without any help, a clerk must open box after box until she finds it. Some days she is lucky. Most days she is not.</p><p>Now add a small cabinet of index cards, sorted by student name. The clerk goes to the card, reads which box and which position, and walks straight to it. The scripts did not move. The registry just gained a second, much smaller list that points into the first.</p><p>That cabinet is a database index. The PostgreSQL manual describes the same difference: without an index, the system has to scan the whole table row by row, while with one it might only walk a few levels deep into a search tree [<a href="https://www.postgresql.org/docs/current/indexes-intro.html">S1</a>]. The manual compares it to the index at the back of a non-fiction book, and notes that just as the author must anticipate what readers will look up, the database programmer must foresee which indexes will be useful [<a href="https://www.postgresql.org/docs/current/indexes-intro.html">S1</a>].</p><p>Indexes are the first tool most people reach for when a database is slow, and they are also the tool most often misused. Today is about using them well, on any of the four databases most teams run: PostgreSQL, SQL Server, Oracle and MongoDB. In a previous post <a href="https://medium.com/@dtechwizard/the-n-1-query-and-other-silent-killers-why-your-app-is-fast-with-10-records-and-slow-with-10-000-94f4bcabc4cf">here</a>, we saw why a page that is instant with 10 records crawls with 10,000: the hidden query loop called N+1. An index makes each of those queries faster, but it cannot turn 10,000 queries into one, so fix the loop first and the lookup second.</p><p>1. Every index has a price<br>Back at the registry, imagine a new script arrives. Before, the clerk simply put it in a box. Now she must also write a card and slot it into the cabinet in the right place. If there are four cabinets (by name, by subject, by date, by school), she writes four cards.</p><p>Databases work the same way. PostgreSQL’s manual says that after an index is created, the system has to keep it synchronized with the table, which adds overhead to data manipulation operations, and that indexes that are seldom or never used should be removed [<a href="https://www.postgresql.org/docs/current/indexes-intro.html">S1</a>]. SQL Server’s design guide says a large number of indexes on a table affects the performance of INSERT, UPDATE, DELETE and MERGE statements, and calls out a common design mistake: creating many indexes speculatively to give the optimizer choices, which slows data modifications and can cause concurrency problems <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">[S5]</a>. Oracle’s documentation says the same: many indexes on a table degrade DML performance because the database must also update them <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/cncpt/indexes-and-index-organized-tables.html">[S7]</a>. MongoDB’s documentation puts it in one line: indexes have a performance cost, but are worth it for frequent queries on large data sets, and indexes that are no longer used should be dropped <a href="https://www.mongodb.com/docs/manual/applications/indexes/">[S10]</a>.</p><p>So the real question is never “should I add an index?” but “which query is slow enough, and run often enough, to be worth a card for every write?”</p><p>2. Which kind of index?<br>For most work, the answer is the default. Oracle’s documentation says B-trees, short for balanced trees, are the most common type of database index, and are excellent for highly selective indexes, where few rows match each entry, and for primary keys <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/cncpt/indexes-and-index-organized-tables.html">[S7]</a>. PostgreSQL, SQL Server and MongoDB all use tree based indexes for ordinary lookups.</p><p>Two specialised cases are worth knowing:<br>Oracle has bitmap indexes, designed mainly for data warehouses and ad hoc queries across many columns, and best for columns with few distinct values on tables that are read only or rarely modified. Oracle says that because one key points to many rows, an update locks all of those rows, so bitmap indexes are not appropriate for many OLTP applications [<a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/cncpt/indexes-and-index-organized-tables.html">S7]</a>. If your system takes lots of small writes from many users, stay with B-tree.</p><p>SQL Server distinguishes clustered from nonclustered indexes. A clustered index stores all rows of the table sorted by the index key, and there can be only one per table. A nonclustered index holds a subset of the columns, sorted differently, plus a pointer back to the row <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">[S5]</a>. The clustered key is part of every nonclustered index on the table, so a narrow clustered key reduces the storage and memory cost of all of them <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">[S5]</a>.</p><p>3. Column order is the whole game<br>Most real queries filter on more than one column. Take a common one: show this customer’s open orders, newest first, 20 at a time.</p><p>A composite index (an index on several columns) is like cards sorted by customer first, then by status within each customer, then by date within each status. The order of columns decides what the index can help with.</p><p>For PostgreSQL, the manual states the rule: equality constraints on leading columns, plus any inequality constraint on the first column without an equality constraint, are what limit the portion of the index scanned <a href="https://www.postgresql.org/docs/current/indexes-multicolumn.html">[S2]</a>. Constraints on columns further right are checked inside the index, which saves visits to the table, but they do not necessarily reduce how much of the index must be read <a href="https://www.postgresql.org/docs/current/indexes-multicolumn.html">[S2]</a>. MongoDB gives a name to its version of this rule, ESRb(Equality fields first), then Sort, then Range. The documentation says equality fields keep the remaining index entries in sorted order, and putting sort fields before range fields avoids in-memory sorts <a href="https://www.mongodb.com/docs/manual/tutorial/equality-sort-range-guideline/">[S9]</a>. It also notes one exception: if your range filter is very selective, you may place it before the sort fields <a href="https://www.mongodb.com/docs/manual/tutorial/equality-sort-range-guideline/">[S9]</a>.</p><p>For our query, the same idea is written in each database like this.<br>PostgreSQL, SQL Server and Oracle:<br><em>sql<br>CREATE INDEX ix_orders_customer_status_created<br>ON orders (customer_id, status, created_at DESC);</em></p><p><em>MongoDB:<br>javascript<br>db.orders.createIndex({ customer_id: 1, status: 1, created_at: -1 })</em></p><p>Customer and status are the equality conditions, so they come first. The creation date is the sort, so it comes next, and the first 20 rows are already in order. This is also what makes the keyset pagination from a previous post <a href="https://medium.com/@dtechwizard/you-think-your-api-is-slow-because-it-takes-time-to-return-a-response-1e0f428c330f">here</a> fast. Keyset pagination only works well when an index matches the sort columns.</p><p>Resist the urge to add more columns. PostgreSQL’s manual says multicolumn indexes should be used sparingly, that a single column index is usually enough, and that indexes with more than three columns are unlikely to be helpful unless the usage of the table is extremely stylized <a href="https://www.postgresql.org/docs/current/indexes-multicolumn.html">[S2]</a>. SQL Server’s guide says to keep indexes narrow, with as few columns as possible, and to avoid over-indexing heavily updated tables <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">[S5]</a>.</p><p>4. Hiding the column hides the index<br>Here is a trap that catches experienced engineers. Suppose users log in by email, and you compare in lower case so that “<a href="mailto:Ada@Example.com">Ada@Example.com</a>” matches “<a href="mailto:ada@example.com">ada@example.com</a>”:<br><em>sql<br>SELECT * FROM users WHERE lower(email) = ‘</em><a href="mailto:ada@example.com"><em>ada@example.com</em></a><em>’;</em></p><p>A plain index on email is sorted by the raw value, not by the lower case value, so the database usually cannot use it for this condition. PostgreSQL’s manual describes exactly this case: the query can use an index if one has been defined on the result of the lower function <a href="https://www.postgresql.org/docs/current/indexes-expressional.html">[S4]</a>. The same idea exists in Oracle as a function-based index, which stores the value of a function or expression and is efficient for statements that contain that function in their WHERE clause <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/cncpt/indexes-and-index-organized-tables.html">[S7]</a>.<br><em>sql<br>- PostgreSQL<br>CREATE INDEX ix_users_lower_email ON users (lower(email));</em></p><p><em>- Oracle<br>CREATE INDEX ix_users_lower_email ON users (LOWER(email));</em></p><p>There is a cost. PostgreSQL’s manual says index expressions are relatively expensive to maintain because the expression must be computed for each row insertion and non-HOT update, so they make sense when retrieval speed matters more than insertion and update speed <a href="https://www.postgresql.org/docs/current/indexes-expressional.html">[S4]</a>. Oracle notes that the database must still evaluate the function on every INSERT and UPDATE <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/cncpt/indexes-and-index-organized-tables.html">[S7]</a>. For SQL Server and MongoDB, check how your version handles an indexed expression or computed value before relying on the same pattern.</p><p>Covering indexes. Sometimes the index can answer the whole question without visiting the table at all, because it already holds every column the query needs. SQL Server calls this a covering index: a nonclustered index that satisfies all data access by a query directly, which reduces disk reads <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">[S5]</a>. You can add extra columns to the leaf level with an INCLUDE clause, so they do not become part of the sort key <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">[S5]</a>. PostgreSQL has the same clause, and says an index-only scan can return the contents of those columns without visiting the table <a href="https://www.postgresql.org/docs/current/sql-createindex.html">[S3]</a>. MongoDB’s documentation says an index supports a query when it contains all the fields the query scans [<a href="https://www.mongodb.com/docs/manual/applications/indexes/">S10]</a>.<br><em>sql<br>- SQL Server and PostgreSQL<br>CREATE INDEX ix_orders_customer_status<br>ON orders (customer_id, status)<br>INCLUDE (total);</em></p><p>Both vendors warn about overdoing it. PostgreSQL says to be conservative about adding non-key columns, especially wide ones <a href="https://www.postgresql.org/docs/current/sql-createindex.html">[S3]</a>. SQL Server says avoid creating a covering index with too many columns, because that diminishes its benefit while inflating storage, I/O and memory use <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">[S5]</a>.</p><p>5. Adding an index to a live system<br>This is where many teams get hurt. On a busy production database, the question is not only “is the index good?” but “what happens to my users while it builds?”</p><p>PostgreSQL. A standard CREATE INDEX locks out writes, though not reads, until it finishes <a href="https://www.postgresql.org/docs/current/sql-createindex.html">[S3]</a>. The manual says that in production environments this is often unacceptable [<a href="https://www.postgresql.org/docs/current/indexes-intro.html">S1</a>]. The CONCURRENTLY option builds the index without taking locks that prevent concurrent inserts, updates or deletes <a href="https://www.postgresql.org/docs/current/sql-createindex.html">[S3]</a>. <br>The trade-offs are real: it must perform two scans of the table, waits for existing transactions, takes significantly longer, and cannot run inside a transaction block <a href="https://www.postgresql.org/docs/current/sql-createindex.html">[S3]</a>. If it fails, for example from a deadlock or a uniqueness violation, it leaves behind an invalid index that is ignored for queries but still consumes update overhead, so you should drop it and try again <a href="https://www.postgresql.org/docs/current/sql-createindex.html">[S3]</a>. Only one concurrent build can run on a table at a time <a href="https://www.postgresql.org/docs/current/sql-createindex.html">[S3]</a>.<br><em>sql<br>CREATE INDEX CONCURRENTLY ix_orders_customer_status_created<br>ON orders (customer_id, status, created_at DESC);</em></p><p>SQL Server. With ONLINE = ON, long-term table locks are not held for the duration of the index operation, and during the main phase only an intent shared lock is held on the source table so queries and updates can continue <a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/create-index-transact-sql?view=sql-server-ver17">[S6]</a>. Brief locks are still taken at the start and end <a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/create-index-transact-sql?view=sql-server-ver17">[S6]</a>. Online index operations are not available in every edition of SQL Server, so check your edition first <a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/create-index-transact-sql?view=sql-server-ver17">[S6]</a>. Newer versions also support RESUMABLE builds, which can be paused and restarted, and require ONLINE = ON <a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/create-index-transact-sql?view=sql-server-ver17">[S6]</a>.<br><em>sql<br>CREATE INDEX ix_orders_customer_status_created<br>ON orders (customer_id, status, created_at DESC)<br>WITH (ONLINE = ON);</em></p><p>Oracle. The ONLINE keyword allows DML on the table during creation of the index <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/CREATE-INDEX.html">[S8]</a>.<br><em>sql<br>CREATE INDEX ix_orders_customer_status_created<br>ON orders (customer_id, status, created_at DESC) ONLINE;</em></p><p>MongoDB. Index builds use an optimized process that holds an exclusive lock on the collection briefly at the beginning and end, while the rest of the build yields to interleaving reads and writes <a href="https://www.mongodb.com/docs/manual/core/index-creation/">[S11]</a>. There is no special flag. The documentation warns that building under heavy write load can reduce write performance and make the build longer, and recommends a maintenance window with reduced writes <a href="https://www.mongodb.com/docs/manual/core/index-creation/">[S11]</a>.<br><em>javascript<br>db.orders.createIndex({ customer_id: 1, status: 1, created_at: -1 })</em></p><p>Test before you commit<br>Oracle can create an INVISIBLE index. It is maintained by DML but is not used by the optimizer unless you set a parameter to allow it, which lets you try an index before exposing it to every query <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/CREATE-INDEX.html">[S8]</a>. In general, try a new index on a copy of production data first, and check the query plan to confirm the database really uses it.</p><p>Clean up<br>PostgreSQL exposes per index statistics in its statistics views, which it describes as useful for determining which indexes are being used and how effective they are <a href="https://www.postgresql.org/docs/current/monitoring-stats.html">[S12]</a>. SQL Server’s guide says to monitor index usage and remove unused indexes over time <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">[S5]</a>. MongoDB’s documentation says to drop indexes that are no longer used <a href="https://www.mongodb.com/docs/manual/applications/indexes/">[S10]</a>. Unused indexes are pure cost: they slow every write and give nothing back.</p><p>The common thread<br>An index is a trade. You pay a little on every write to save a lot on specific reads. That trade is excellent when the query is frequent, selective and well matched to the index, and wasteful when it is none of those.</p><p>A quick audit for your busiest table<br>List your table indexes and for each one, name the query that needs it. Check whether the column order matches that query’s equality, sort and range conditions. Look for functions wrapped around indexed columns in your queries. Then find the indexes nobody uses. The ones with no query to name are the first to go.</p><p>Sources<br>S1: PostgreSQL manual, Indexes: Introduction. <a href="https://www.postgresql.org/docs/current/indexes-intro.html">https://www.postgresql.org/docs/current/indexes-intro.html</a><br>S2: PostgreSQL manual, Multicolumn Indexes. <a href="https://www.postgresql.org/docs/current/indexes-multicolumn.html">https://www.postgresql.org/docs/current/indexes-multicolumn.html</a><br>S3: PostgreSQL manual, CREATE INDEX (CONCURRENTLY and INCLUDE). <a href="https://www.postgresql.org/docs/current/sql-createindex.html">https://www.postgresql.org/docs/current/sql-createindex.html</a><br>S4: PostgreSQL manual, Indexes on Expressions. <a href="https://www.postgresql.org/docs/current/indexes-expressional.html">https://www.postgresql.org/docs/current/indexes-expressional.html</a><br>S5: Microsoft Learn, SQL Server Index Architecture and Design Guide. <a href="https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/relational-databases/sql-server-index-design-guide?view=sql-server-ver17</a><br>S6: Microsoft Learn, CREATE INDEX (Transact-SQL). <a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/create-index-transact-sql?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/t-sql/statements/create-index-transact-sql?view=sql-server-ver17</a><br>S7: Oracle Database Concepts, Indexes and Index-Organized Tables. <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/cncpt/indexes-and-index-organized-tables.html">https://docs.oracle.com/en/database/oracle/oracle-database/19/cncpt/indexes-and-index-organized-tables.html</a><br>S8: Oracle Database SQL Language Reference, CREATE INDEX. <a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/CREATE-INDEX.html">https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/CREATE-INDEX.html</a><br>S9: MongoDB manual, The ESR (Equality, Sort, Range) Guideline. <a href="https://www.mongodb.com/docs/manual/tutorial/equality-sort-range-guideline/">https://www.mongodb.com/docs/manual/tutorial/equality-sort-range-guideline/</a><br>S10: MongoDB manual, Indexing Strategies. <a href="https://www.mongodb.com/docs/manual/applications/indexes/">https://www.mongodb.com/docs/manual/applications/indexes/</a><br>S11: MongoDB manual, Index Builds on Populated Collections. <a href="https://www.mongodb.com/docs/manual/core/index-creation/">https://www.mongodb.com/docs/manual/core/index-creation/</a><br>S12: PostgreSQL manual, The Cumulative Statistics System. <a href="https://www.postgresql.org/docs/current/monitoring-stats.html">https://www.postgresql.org/docs/current/monitoring-stats.html</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=79434e53666b" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What Happens When 100,000 Devices Wake Up at the Same Second]]></title>
            <link>https://dtechwizard.medium.com/what-happens-when-100-000-devices-wake-up-at-the-same-second-d13afd7a26ab?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/d13afd7a26ab</guid>
            <category><![CDATA[firmware]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[iot]]></category>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[mqtt]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Thu, 29 Oct 2026 08:59:56 GMT</pubDate>
            <atom:updated>2026-10-29T08:59:56.614Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CEgxkzmig2PdRG9DKXkRig.jpeg" /></figure><p>Imagine a city where every street lamp can phone a control room. One evening the power fails. When it returns, 100,000 lamps notice they are offline and try to call in at the exact same second.</p><p>The control room is not broken. The lamps are not broken either. The problem is that every device made the same sensible decision at the same moment. Most device fleets that fall over do so for this reason. It has little to do with any single device and everything to do with what happens when thousands of them behave identically.</p><p>This is the world of IoT: meters, sensors, vehicles, point-of-sale terminals, lamps, pumps and thermostats. The earlier days covered what happens inside your servers and databases. Today is about the part that sits outside them, a crowd of small devices on weak networks that you do not control. Four decisions matter most.</p><p>1. The language devices speak: MQTT<br>Most fleets talk through MQTT, a lightweight publish and subscribe protocol standardized by OASIS and ISO. Instead of every device calling your server directly, devices connect to a middle server called a broker. A device publishes a message to a named topic, and any system interested in that topic subscribes to it. The sender and receiver never need to know about each other, and the smallest MQTT packet is only 2 bytes, which suits small devices.</p><p>A simple topic layout helps you scale later. For example:<br><em>fleet/{region}/{device_id}/telemetry<br>fleet/{region}/{device_id}/status<br>fleet/{region}/{device_id}/commands</em></p><p>A backend service can then subscribe to fleet/+/+/telemetry to receive readings from every device (in MQTT, + stands for “any single level”), while a dashboard watches only one region.</p><p>How sure must delivery be? MQTT gives you three levels, called QoS. QoS 0 is at most once: send it and forget it, so a message can be lost. QoS 1 is at least once: the sender keeps the message until the receiver acknowledges it, so it always arrives but may arrive more than once. QoS 2 is exactly once: a four step handshake prevents duplicates, but it costs more traffic. One more rule to know: the level actually used is the lower of what the publisher sent and what the subscriber asked for.</p><p>A common choice for readings is QoS 1, because losing a measurement is usually worse than receiving it twice. But that choice has a consequence. Duplicates will arrive, so your backend must handle them. This is the same lesson as in a previous post <a href="https://medium.com/@dtechwizard/do-not-make-the-user-wait-a-practical-guide-to-queues-and-background-jobs-and-why-your-job-will-fcf459494bbf">here</a>, where queues deliver a job at least once and the same job can run twice, and in a previous post <a href="https://medium.com/@dtechwizard/you-think-your-api-is-slow-because-it-takes-time-to-return-a-response-1e0f428c330f">here</a>, where an idempotency key lets a server recognise a repeated request and return the same answer instead of doing the work again. Give each reading a unique ID and ignore ones you have already stored.</p><p>Knowing which devices are online. Two MQTT features work together here. When a device connects, it can register a Last Will and Testament, which is a message the broker publishes for it if the device vanishes without saying goodbye. After connecting, the device publishes a retained “online” message to the same topic, and a retained message is kept by the broker so anyone who subscribes later still sees the latest state. If the device drops, the broker publishes the “offline” will, which replaces the retained message. When it returns, its new “online” message replaces the will. Anyone can check one topic and know the device’s status. <a href="https://www.hivemq.com/mqtt/">HiveMQ</a> describes this pattern in managing device state in MQTT. The basic version only covers ungraceful disconnects, so a graceful goodbye needs its own message.</p><p>Scaling the consumers. With MQTT 5, you can use shared subscriptions. Normally every subscriber gets every message. With a shared subscription such as $share/ingest/fleet/+/+/telemetry, a group of workers named “ingest” splits the traffic, and each message goes to only one worker. If a worker disconnects before acknowledging a QoS 1 message, the broker resends it to another worker in the group. There is no ordering guarantee between workers, so do not rely on order across devices.</p><p>2. The reconnect storm<br>Back to the street lamps. After an outage, every device thinks the same thing: “I am offline, retry.” If every device retries after exactly 5 seconds, then 10, then 20, the broker is hit by the entire fleet at once, again and again.</p><p>The textbook fix has two parts. First, exponential backoff: wait longer after each failure. Second, jitter: add a random element so devices do not move in lockstep. Backoff alone is not enough, because devices that started together still stay bunched. The <a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/">AWS Architecture Blog</a> simulated this and found that the version without jitter was the clear loser, doing more work and taking longer, and that full jitter cut the number of calls by more than half with 100 contending clients.</p><p>The “full jitter” formula is short and works in any language:</p><p><em>delay = random_between(0, min(cap, base * 2 ^ attempt))</em></p><p>Pick a base such as 1 second and a cap such as a few minutes. Each device draws its own random delay, so the crowd spreads out into a steady trickle. Also make devices wait a random moment after power returns before the first attempt, because the first attempt is where the biggest spike happens.</p><p>The server also needs a defence for when devices misbehave anyway. That is the job of rate limiting, covered in a previous post <a href="https://medium.com/@dtechwizard/why-every-good-system-learns-to-say-full-a-practical-guide-to-rate-limiting-backpressure-and-441a9fb86676">here</a>, where a bus conductor says “full” so the bus does not break. Keep alive settings matter too. Platforms have bounds: AWS IoT Core, for example, treats a requested keep alive of 30 to 1,200 seconds as the effective range. Shorter intervals detect dead devices faster but create more traffic across a large fleet.</p><p>3. Ingestion: do not wire the broker straight to the database<br>Readings arrive in bursts. If the broker pushes every message directly into the database, a burst turns into a pile of simultaneous writes, and the database falls over, taking your history with it.</p><p>A sturdier path puts a queue between them: devices publish to the broker, workers pull from a shared subscription or a queue and write in batches, and the data lands in a store built for it. A burst then waits in line instead of crashing the writers, which is the idea behind a previous post <a href="https://medium.com/@dtechwizard/do-not-make-the-user-wait-a-practical-guide-to-queues-and-background-jobs-and-why-your-job-will-fcf459494bbf">here</a>. Batching also happens at the device: sending ten readings in one message is usually cheaper than ten messages. Mind the limits of your platform. AWS IoT Core url, for example, rejects a publish larger than 128 KB, and that limit cannot be raised.</p><p>How big can a broker get? Vendors publish their own tests, and they are worth reading with care. <a href="https://dzone.com/articles/reaching-100-m-mqtt-connections-with-emqx-50">EMQX</a> reported 100 million MQTT connections on a 23 node cluster, with a steady background of about 90,000 messages per second and peaks above 1 million. The same write-up says it used custom forks of tools to run the test, connected load generators directly to some nodes with no load balancer, and ran on large bare metal instances. It is a proof that the ceiling is high, not a promise that your fleet will behave like that.</p><p>For storage, time series data grows fast and is rarely edited, so plan for keeping recent data detailed and older data summarised. We will go deeper on partitioning and archiving in the database days later in this series.</p><p>4. Updating firmware without bricking the fleet<br>An update is the riskiest thing you will ever send to a fleet. A bad version can leave thousands of devices unable to start, often in places where nobody can walk up and reset them. Four habits reduce the risk.<br>- Write to a spare partition. In an A/B update design, the device runs on partition A while the new version is written to partition B. Only after the write succeeds does the bootloader switch to B. If the new version fails, the device rolls back to A and stays usable. The running system is never overwritten, which also protects against a power cut halfway through the write.<br>- Confirm before committing. Do not mark the update as good just because the device booted. Run checks first, such as network connectivity and whether the critical application started, and revert if boot fails a set number of times or no success report arrives within a timeout.<br>- Sign the image. Code signing means the device checks a digital signature before it runs new firmware, so it accepts only your code. Validate the hash or signature before boot.<br>- Roll out in waves. Start with a small canary group, watch the results, then widen. Platforms help here. <a href="https://docs.aws.amazon.com/iot/latest/developerguide/jobs-configurations-details.html">AWS IoT Jobs</a> lets you set a rollout rate, for example starting at 50 devices per minute and doubling as the notified or succeeded count reaches 1,000, then 2,000, then 3,000 (the page’s own example). You can also set abort criteria, such as a threshold percentage of failures, so the rollout stops itself. <br>Note the details: when an abort fires, queued executions are canceled but those already in progress are not, and retries (up to 10 per device) can add cost, so AWS recommends pairing them with an abort setting. The same ideas exist on other platforms.</p><p>The common thread<br>All four decisions come from one habit: assume every device will do the same thing at the same moment, then design so that this is fine. Randomised retries, queues in front of databases, duplicate tolerant handlers and wave based updates are not clever, but they are what keep a fleet boring on the worst day.</p><p>A quick audit for your own fleet: what do devices do when the network returns, what happens to a message delivered twice, how many writes can your database take in one second, and how would you stop a bad update at 2 percent instead of 100?</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d13afd7a26ab" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[You think your API is slow because it takes time to return a response?]]></title>
            <link>https://dtechwizard.medium.com/you-think-your-api-is-slow-because-it-takes-time-to-return-a-response-1e0f428c330f?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/1e0f428c330f</guid>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[sql]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[data-science]]></category>
            <category><![CDATA[database]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Wed, 28 Oct 2026 08:41:35 GMT</pubDate>
            <atom:updated>2026-10-28T08:41:35.662Z</atom:updated>
            <content:encoded><![CDATA[<h3>You think your API is slow because it takes time to return a response? No, it isn’t; it’s just talking too much.</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/720/1*chz4cPfZHJ0Z1gQaYWoLNA.png" /></figure><p>Imagine you walk into a huge library and ask for page 50,000 of the catalogue. The librarian has no bookmark, so she starts at page 1 and counts. Asking for page 1 is instant. Asking for page 50,000 takes minutes, and everyone queued behind you waits too.</p><p>A lot of APIs treat their database exactly like that librarian. Nothing is wrong with the code, the server is healthy and the database is not even busy by the usual measures, yet the conversation between the app and the server is wasteful. At small scale nobody notices. At large scale, that conversation is often the biggest cost in the whole system.</p><p>I want to walk through four decisions that shape it. They are language and framework neutral, so they apply whether your backend is Node, Java, .NET, Python or anything else.</p><p>1. How you turn the page<br>The most common way to paginate is offset pagination: skip some rows, return the next batch. In plain terms, “skip 1,000,000 rows and give me 20”.</p><p>The problem is that the database cannot teleport. To skip a million rows it has to walk past them. The PostgreSQL manual states it plainly: the rows skipped by an OFFSET clause still have to be computed inside the server, so a large OFFSET might be inefficient. The MongoDB documentation says the same about skip(): the server must scan from the beginning of the result set, and skip() becomes slower as the offset grows. The EF Core documentation from Microsoft describes the same cost, and adds a second problem. If rows are added or deleted while a user is paging, results can shift, so one entry gets skipped or shown twice. Microsoft’s SQL Server documentation says stable OFFSET and FETCH paging needs two conditions: the underlying data does not change between page requests, and the ORDER BY contains a column or combination of columns guaranteed to be unique.</p><p>The alternative is keyset pagination, sometimes called the seek method or cursor pagination. Instead of “skip N rows”, the client says “give me the next 20 rows after the last one I saw”. With an index on the sort columns, the database jumps straight to that spot, so page 50,000 costs about the same as page 1. The MongoDB documentation recommends exactly this range query approach, and the EF Core documentation calls keyset the recommended alternative to offset.</p><p>Here is the same idea in four databases. Assume an orders table sorted newest first, with a tie breaker on id so the order is unique, and an index on (created_at, id). The last row the client saw had created_at = :last_created and id = :last_id.</p><p>PostgreSQL:<br><em>sql<br>SELECT id, created_at, total<br>FROM orders<br>WHERE (created_at, id) &lt; (:last_created, :last_id)<br>ORDER BY created_at DESC, id DESC<br>LIMIT 20;</em></p><p>SQL Server (OFFSET 0 with FETCH works since SQL Server 2012, and TOP works too):<br><em>sql<br>SELECT id, created_at, total<br>FROM orders<br>WHERE created_at &lt; </em><a href="http://twitter.com/last_created"><em>@last_created</em></a><em><br> OR (created_at = </em><a href="http://twitter.com/last_created"><em>@last_created</em></a><em> AND id &lt; </em><a href="http://twitter.com/last_id"><em>@last_id</em></a><em>)<br>ORDER BY created_at DESC, id DESC<br>OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY;</em></p><p>Oracle (row limiting clause, available from 12c):<br><em>sql<br>SELECT id, created_at, total<br>FROM orders<br>WHERE created_at &lt; :last_created<br> OR (created_at = :last_created AND id &lt; :last_id)<br>ORDER BY created_at DESC, id DESC<br>FETCH FIRST 20 ROWS ONLY;</em></p><p>MongoDB:<br><em>javascript<br>db.orders.find({<br> $or: [<br> { createdAt: { $lt: lastCreated } },<br> { createdAt: lastCreated, _id: { $lt: lastId } }<br> ]<br>}).sort({ createdAt: -1, _id: -1 }).limit(20)</em></p><p>The expanded OR form in the last three works on every engine. PostgreSQL also accepts the shorter row comparison shown first.</p><p>Keyset pagination has a real trade-off, and it is better to say so. It does not support random access. A user cannot jump straight to page 500, because the database needs a starting point. Microsoft’s own guidance says it suits interfaces where users move forward and backward, and suggests a hybrid when you also need jumps. For feeds, timelines, logs, order histories and infinite scroll, which is most of the traffic on most APIs, it is the better fit. For the rare “go to page 500” screen, keep offset but limit how deep it can go.</p><p>Two habits make this safe at scale. First, hand the client an opaque cursor instead of raw column values, so you can change the internals later. Stripe’s list endpoints show the shape: you pass starting_after with the ID of the last object you received, and the response carries a has_more flag telling you whether another page exists. Second, cap the page size. Stripe’s limit parameter accepts 1 to 100 with a default of 10, and GitHub’s documentation says the maximum per_page is 100 on most endpoints, silently reducing larger values to 100. A cap stops one client from asking for everything at once.</p><p>2. How heavy each answer is<br>The next cost is the size of what you send back. Many responses carry forty fields when the screen shows five. Sending only what the screen needs, through field selection or purpose built response shapes, shrinks the payload before compression even starts.</p><p>Then compress what remains. Gzip is supported by essentially every server and browser. Brotli is newer and, according to measurements from Paul Calvano’s analysis of web compression, can often reduce files 15 to 25 percent more than gzip at its highest level. Zstandard arrived in Chrome from version 123, released in March 2024. The catch is CPU. The same analysis notes that Brotli’s higher levels come at a significant latency cost, which is why servers default to middle levels for content generated on the fly (for example, Apache’s mod_brotli defaults to level 5). The practical rule is to compress text such as JSON, HTML and CSS, skip files that are already compressed such as images and video, and use a middle level for live responses.</p><p>One security caution belongs here. The BREACH attack can work against sites that use HTTP compression, reflect user input in the response, and also reflect a secret such as a CSRF token in the same response body. If your pages or responses mix a secret with attacker influenced text, talk to your security team before assuming compression is free.</p><p>3. How many trips<br>Every request has a fixed cost, however small its content. If drawing one screen takes ten separate calls, and some of them wait for earlier ones to finish, the user feels the sum of all ten waits. This is the same shape as the N+1 query problem inside a database, which I covered in a previous post here, only it happens across the network.</p><p>There are two honest fixes. The first is batching or combining: let the client fetch what it needs for a screen in one call, or send several operations in one request. Keep batches bounded, and return a result for each item so that one failure does not hide the other nine successes. The second is not asking at all when nothing changed. A server can attach an ETag, which is a version label, to a response. On the next request the client sends that label back in an If-None-Match header. If the data has not changed, the server replies with 304 Not Modified, which by definition has no body, and the client reuses its cached copy. That saves the bytes, and if checking the version is cheap on your side, it saves the work too.</p><p>4. What happens when the same request arrives twice<br>This is the one that costs money. Networks time out, phones lose signal, load balancers retry, and message queues deliver at least once. So the same request will eventually arrive twice. If that request is “charge this customer” or “create this order”, you now have a duplicate.</p><p>The standard answer is an idempotency key. The client generates a unique key for each logical action and sends it with the request. The server records the key with the result. If the same key arrives again, the server returns the stored result instead of doing the work a second time.</p><p>There is active standards work on this. The IETF’s HTTP API working group has an Internet-Draft called the Idempotency-Key HTTP Header Field. It is still a draft, not a published standard, so treat it as a direction rather than a rule. It targets methods such as POST and PATCH, which are not naturally safe to repeat, and it suggests three responses worth copying: 409 when the same key arrives while the first request is still running, 422 when the same key is reused with a different request body, and 400 when an endpoint that requires the key does not receive one.</p><p>Store keys with an expiry, check the stored request against the new one, and make the key check and the operation succeed or fail together, ideally inside one database transaction.</p><p>The common thread<br>All four choices come from the same habit: design the conversation for the worst day, not the demo day. Bounded pages, small answers, few trips, and safe retries are boring to build, and they are exactly what keeps an API calm when traffic is 100x higher than the test environment.</p><p>If you want a quick audit, pick your busiest endpoint and ask four questions: <br>1. How deep can a client page? <br>2. How many bytes does the biggest response carry, and is it compressed? <br>3. How many calls does its main screen make? <br>4. What happens if the same request is sent twice?</p><p>The answers usually point to the first thing worth fixing.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1e0f428c330f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Hidden Numbers That Decide How Many Users You Can Serve: Connection Pools, Worker Limits, and…]]></title>
            <link>https://dtechwizard.medium.com/the-hidden-numbers-that-decide-how-many-users-you-can-serve-connection-pools-worker-limits-and-0647f8afe505?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/0647f8afe505</guid>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[database]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[load-balancing]]></category>
            <category><![CDATA[connection-pooling]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Mon, 26 Oct 2026 11:54:12 GMT</pubDate>
            <atom:updated>2026-10-26T11:54:12.421Z</atom:updated>
            <content:encoded><![CDATA[<h3>The Hidden Numbers That Decide How Many Users You Can Serve: Connection Pools, Worker Limits, and Load Balancers</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r1kmmQ7wOWvzgDy0bLTD_Q.jpeg" /></figure><p>The petrol station<br>A petrol station has four pumps. Cars arrive, fill up and leave, and life is good. Then one driver parks at a pump and walks off to buy snacks. The station now has three working pumps, and a queue is forming behind a car that is not even fuelling.</p><p>Your database is that station, and the pumps are connections. Each request needs one to talk to the database, and the number is limited. The driver who wanders off is a request that keeps its connection while it waits for something slow, such as a call to a payment partner.</p><p>The big idea: capacity hides in config files<br>Your app’s real capacity is rarely set by CPU. It is set by small numbers that nobody reviews:<br>- How many connections the database allows<br>- How big each app instance’s connection pool is<br>- How many workers or threads each instance runs<br>- How long the load balancer and your app keep idle connections open</p><p>If these numbers disagree with each other, the weakest one decides your ceiling.</p><p>Surprise 1: a bigger pool is often slower<br>The HikariCP documentation, the connection pool used by most Java applications, makes a striking point using a demonstration from Oracle’s Real-World Performance group. Reducing the pool from 2,048 connections to 96, with no other change, cut response times from about 100ms to about 2ms, more than a 50 times improvement. The wiki goes further and argues that even 96 is probably too many unless the database server has a lot of cores.</p><p>Why? A database can only do so much work at once. With far more active connections than CPU cores, the database wastes time switching between them. The best design is a small pool kept busy, with the rest of your requests waiting in line inside the application, where waiting is cheap.</p><p>The wiki’s starting formula, which it says has held up across benchmarks for years, is connections equals twice the database server’s core count plus the number of effective disk spindles. Two things to remember: it is a starting point to test, and it applies to the database, not to each app instance. Pool sizing is ultimately specific to your deployment.</p><p>You can also size from traffic using Little’s Law: connections in use equals queries per second times the seconds each query holds a connection. At 2,000 queries per second and 50 ms each, about 100 connections are busy. If the query slows to 500 ms, you suddenly need 1,000, and the pool becomes your bottleneck before the CPU does.</p><p>Surprise 2: autoscaling can take down your own database<br>Here is a failure that catches even experienced teams. Each instance of your app keeps a pool, so total connections equal the pool size times the number of instances.<br>- 4 instances with a pool of 50 each is 200 connections.<br>- A traffic spike triggers autoscaling to 40 instances, and you now ask for 2,000 connections.<br>- The database, already under stress, is hit by a wall of new connections.</p><p>PostgreSQL’s default max_connections is 100. SQL Server’s ADO.NET pool defaults to a maximum of 100 per connection string, and MongoDB drivers default to up to 100 open connections in the pool. So the multiplication happens quickly. Serverless functions make it worse, because each concurrent execution can open its own connection.</p><p>Fixes: put a connection pooler or proxy between the app and the database, keep per instance pools small, cap your maximum instance count according to your database limit, and give each service its own database role with a connection limit.</p><p>Your database, one by one<br>1. PostgreSQL: each client connection gets its own backend process, which is why connections are expensive and why the default limit is 100. PgBouncer is the most widely used pooler. It supports session pooling (the default), transaction pooling and statement pooling. Transaction mode gets the most out of few connections, but it breaks some session features, such as LISTEN/NOTIFY, and session level settings. Since PgBouncer 1.21, protocol level prepared statements are tracked per client, which fixed a long standing pain point, so check your version.<br>2. SQL Server: the ADO.NET pool keeps a separate pool for each distinct connection string, so small differences in a string can create extra pools. The default maximum is 100, and when it is reached, requests queue. If they wait too long you get the familiar error saying the timeout elapsed before a connection could be obtained from the pool. In Azure SQL Database, connection limits also depend on the service tier you chose.<br>3. MongoDB: drivers keep their own pool per client instance, with a default maximum of 100 connections, so many app instances multiply quickly. Set maxPoolSize deliberately, and watch the total across your fleet.<br>4. Oracle: sessions and processes are limited at the database, and Oracle offers connection pooling options such as Database Resident Connection Pooling and the Universal Connection Pool, so check what fits your architecture.<br>5. MySQL: the same idea applies, with a max_connections limit and proxies such as ProxySQL.</p><p>How pools go wrong<br>1. Leaks. Code that opens a connection and forgets to release it on an error path. Always release in a finally block or a using style construct.<br>2. Holding a connection while doing something slow. Never keep a connection or an open transaction while calling an external API. Get the data, release the connection, then call out.<br>3. Long transactions. In PostgreSQL, sessions stuck in an “idle in transaction” state hold connections and locks without doing anything.<br>4. Chatty code. The N+1 pattern from one of my previous posts <a href="https://medium.com/@dtechwizard/the-n-1-query-and-other-silent-killers-why-your-app-is-fast-with-10-records-and-slow-with-10-000-94f4bcabc4cf">here</a> holds a connection for much longer than needed.<br>5. Long wait timeouts. If a request waits 30 seconds for a connection, everything behind it is stuck too. Make the connection acquisition timeout shorter than your request timeout, so you fail fast and visibly.</p><p>Watch these signals: connections in use, connections idle, and above all the number of requests waiting for a connection. In HikariCP with Micrometer, for example, a pending connections metric shows threads waiting.</p><p>Worker limits — three numbers that must agree. The traffic path has three limits in a row:<br>- Concurrent requests the load balancer will send to each instance<br>- Workers or threads in each instance<br>- Connections in the pool, which should be fewer than the workers</p><p>Typical examples: Node.js runs one event loop per process, so you run several processes. Python servers such as gunicorn use a configured number of worker processes. PHP-FPM has a maximum number of child processes. Java servers use thread pools. .NET uses its thread pool. Many workers share a small pool, and that is fine, as long as the waiting queue is bounded and has a timeout.</p><p>Load balancing in plain words<br>A load balancer is the traffic warden sending cars to pumps. Things worth knowing:<br>1. Layer 4 and layer 7. A layer 4 balancer forwards connections without reading them. A layer 7 balancer reads HTTP, so it can route by path or header and apply smarter rules.<br>2. Algorithms. Round robin is simple. Least connections adapts when some requests are slow. Weighted variants help when servers differ. Hash-based routing keeps a key on the same server. Some balancers also offer “power of two choices,” picking the better of two random servers, which works well at scale.<br>3. Health checks. A shallow check asks “is the process alive?” A deep check queries dependencies such as the database. Be careful with deep checks: if the database blips, every instance fails its check at the same time, the balancer removes them all, and a small problem becomes a total outage. Keep the load balancer’s check shallow, and use separate readiness and liveness endpoints in orchestration systems.<br>4. Graceful deploys. When an instance is removed, it should stop accepting new requests, finish current ones, then exit. Load balancers have a draining or deregistration delay for this, and orchestrators have a termination grace period, which in Kubernetes defaults to 30 seconds. Make sure the app handles the shutdown signal.<br>5. Stateless apps. Avoid sticky sessions where you can, and keep sessions in a shared store, so any server can serve any user.</p><p>The timeout trap behind random 502 errors<br>This one confuses even experienced engineers. An AWS Application Load Balancer keeps idle connections to your servers open for 60 seconds by default. Node.js closes idle connections after 5 seconds by default. In the unlucky moment when a new request arrives just as the server closes the connection, the load balancer sends the request into a dead connection and reports a 502 Bad Gateway, even though your app is healthy.</p><p>The rule: the backend’s keep-alive timeout must be longer than the load balancer’s idle timeout. For a 60-second balancer, a common setting is 65 seconds. In Node.js, also keep headersTimeout higher than keepAliveTimeout. The same logic applies in other stacks, such as the — keep-alive option in gunicorn. A good general ordering is that the client’s idle timeout is shortest, then the load balancer, then the backend.</p><p>The scale lens<br>- The connection arithmetic. 50 per instance times 40 instances is 2,000 connections against a default limit of 100. At the same time, 2,000 queries per second at 50 ms is only about 100 busy connections. The danger is not usually the work; it is the number of idle and wasted connections.<br>- Connection storms. After a deploy, a database failover, or an outage, every instance reconnects at once. Use backoff with jitter on reconnects, stagger rolling deployments, and avoid opening a full pool at startup.<br>- Pools hide slow queries until they do not. A query that gets ten times slower needs ten times the connections, so a slow query can look like a connection problem.<br>Retries multiply. Load balancer retries, client retries, and application retries stack up. Keep one layer in charge of retries.<br>- Capacity plan the whole chain, from load balancer to workers to pool to database, with load tests, and know which limit breaks first.</p><p>A security note<br>- Least privilege per service. Give each service its own database credentials with only the permissions it needs, and cap connections per role where the database supports it.<br>- Encrypt in transit. Use TLS between the load balancer, the app, and the database, and do not expose the database or the pooler to the public internet.<br>- Be careful with forwarded headers. Many apps read the client’s IP from headers set by proxies. If your app trusts them from anywhere, attackers can fake them and bypass IP-based limits; see more about that <a href="https://medium.com/@dtechwizard/why-every-good-system-learns-to-say-full-a-practical-guide-to-rate-limiting-backpressure-and-441a9fb86676">here</a>. Only trust them from your known proxies.<br>- Keep health endpoints minimal. Do not reveal versions, dependency names or internal details to the public.<br>- Slow connection attacks. Short timeouts for headers and bodies protect your workers from clients that hold connections open on purpose.<br>- Rotate credentials safely. Set a maximum connection lifetime in your pool, so rotated credentials take effect without a restart.</p><p>The tradeoffs<br>Poolers and proxies add a hop, a component to run, and sometimes restrictions, such as transaction mode limits.<br>Small pools make requests wait, which is intentional, but you must set sensible timeouts and monitor the queue.<br>Aggressive health checks improve failover speed but risk false alarms and flapping.<br>Long keep-alive timeouts use more resources on the server.<br>A limit that is too tight wastes hardware, and one that is too loose lets a spike take everything down.</p><p>Tools to start with<br>- Pool metrics from your library, such as HikariCP with Micrometer, ADO.NET performance counters, and driver metrics<br>- pg_stat_activity in PostgreSQL and the equivalent session views in SQL Server, Oracle, and MongoDB, to see who holds connections<br>- Poolers and proxies: PgBouncer, ProxySQL, and the managed database proxies your cloud provider offers<br>- Load balancer metrics: target response time, 5xx counts, connection counts<br>- Load tests that scale instance counts to catch the connection multiplication before production does</p><p>In your stack. Check each one’s documentation for your version:<br>- Java: HikariCP<br>- .NET: ADO.NET pooling and EF Core’s DbContext pooling<br>- Node.js: pg Pool, Prisma’s connection limit setting, and the pool options in TypeORM and Sequelize<br>- Python and Django: persistent connections with CONN_MAX_AGE, and native pooling in recent Django versions<br>- PHP and Laravel: PHP-FPM keeps a connection per process, so use a pooler such as PgBouncer or ProxySQL for large fleets<br>- Go: SetMaxOpenConns and its relatives in database/sql<br>- MongoDB drivers: maxPoolSize</p><p>Sometimes the biggest limits are not in your code. They are in the numbers nobody reviews.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=0647f8afe505" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why Every Good System Learns to Say “Full”: A Practical Guide to Rate Limiting, Backpressure and…]]></title>
            <link>https://dtechwizard.medium.com/why-every-good-system-learns-to-say-full-a-practical-guide-to-rate-limiting-backpressure-and-441a9fb86676?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/441a9fb86676</guid>
            <category><![CDATA[system-design-concepts]]></category>
            <category><![CDATA[api-design]]></category>
            <category><![CDATA[rate-limiting]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[backend-development]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Sun, 25 Oct 2026 02:32:29 GMT</pubDate>
            <atom:updated>2026-10-25T02:32:29.963Z</atom:updated>
            <content:encoded><![CDATA[<h3>Why Every Good System Learns to Say “Full”: A Practical Guide to Rate Limiting, Backpressure and Load Shedding</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MuMII6ve2dWclcN6zbvJTw.jpeg" /></figure><p>The conductor<br>A bus conductor in a busy city has one job that matters more than any other: knowing when to say “full.” A bus that takes every passenger who shows up does not carry more people. It breaks its axle, and nobody gets to work.</p><p>A conductor who says “full” is not being unkind. He is the reason the bus keeps moving.</p><p>Your app has the same problem. When more traffic arrives than the system can handle, you have two choices. You can accept everything and fail slowly for everyone, or you can say no quickly to some requests and keep serving the rest.</p><p>These terms are often mixed up, so here they are in plain words<br>1. Rate limiting: a rule about how many requests one client may make in a period of time, for example 10 per second.<br>2. Quotas: a larger allowance over a longer period, for example 100,000 requests per month on a pricing plan.<br>3. Load shedding: when the whole system is under pressure, deliberately dropping low priority work so critical work survives.<br>4. Backpressure: when one part is full, it signals the parts feeding it to slow down, instead of letting work pile up without limit.</p><p>Did you know that most time, your worst client is usually you?<br>When people think about limits, they imagine attackers. In practice, the biggest sources of overload are friendlier and closer to home:<br>- Your own mobile app retrying in a loop after an error<br>- A partner’s script with a bug<br>- Your own service hammering another one after an outage<br>- A well meaning customer running a huge export at peak time</p><p>And there is a second surprise: where a request comes from is a weak identity. On mobile networks, many real customers can share one IP address, so an IP based limit can block innocent people, while IPs are also easy to fake. OWASP’s guidance for API protection recommends configuring limits on other keys, such as tokens or fingerprints, where possible. Limit by who the caller is, such as the API key, account or user token, and use IP only as an extra signal.</p><p>The algorithms, in plain words<br>- Fixed window. Count requests in each time slice, such as 100 per minute, and reset at the boundary. It is simple, but it allows a burst at the edge: 100 requests at the last second of one minute and 100 in the first second of the next, so 200 in two seconds.<br>- Sliding window. Spreads the counting across the boundary and smooths that burst, at the cost of more bookkeeping.<br>- Token bucket. Each client has a bucket that refills at a steady rate, say 10 tokens per second, up to a maximum of 20. Each request spends a token. A client who has been quiet can burst 20 requests, then settles at 10 per second on average. Stripe documents that it uses a token bucket for its request rate limiter.<br>- Leaky bucket. Smooths traffic into a steady outflow, useful when a downstream system needs an even pace.<br>- Concurrency limiter. Instead of counting requests over time, it caps how many are in flight at once. This suits expensive endpoints, because the cost is how long each request holds a worker.</p><p>Stripe’s four layers<br>Stripe published how it protects its API in production, and the design is a good template:<br>1. A request rate limiter (token bucket), which Stripe calls by far the most important and recommends starting with.<br>2. A concurrent requests limiter, to control CPU intensive endpoints.<br>3. A fleet usage load shedder, which reserves a share of capacity for critical requests, so non-critical ones are rejected when the fleet is busy.<br>4. A worker utilization load shedder, the last line of defense, which starts dropping lower priority traffic when a machine is under heavy pressure.</p><p>What to return when you say no<br>- HTTP 429 Too Many Requests when a client exceeded its limit, or 503 Service Unavailable when the server itself is overloaded.<br>- A Retry-After header, which tells the client how long to wait. HTTP defines it for 429 and 503 responses.<br>- Optional limit headers. An IETF draft proposes the RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset fields. It was still a draft in the versions I found, and many APIs use their own names, so document whichever you choose.<br>- A clear message. Say which limit was hit, so the developer on the other side can fix it.</p><p>Where to enforce limits<br>Use several layers, from the outside in:<br>- The edge: a CDN or WAF handles huge volumes and abusive patterns, including volumetric attacks, before they reach you.<br>- The gateway or reverse proxy: NGINX, Envoy, Kong, and the cloud API gateways, for per-key limits.<br>- The application: business aware limits, such as per tenant, per plan and per endpoint.<br>- The workers: concurrency caps on queue consumers, so they cannot overload the database.<br>- The database itself: the last wall.</p><p>Protect the database directly<br>A limit at the front does not help if one expensive query can still flatten your database. Every serious database offers ways to cap the damage:<br>- PostgreSQL: a connection pooler to cap connections, and statement_timeout to stop runaway queries.<br>- SQL Server: Resource Governor, to limit how much CPU and memory a workload can take.<br>- Oracle: Database Resource Manager, for the same purpose.<br>- MongoDB: maxTimeMS to cap query time, and connection pool limits in the drivers.<br>- All of them: set sensible pool sizes.</p><p>Backpressure in practice<br>- Bound your queues. An unbounded queue does not remove the overload, it only postpones the crash and hides the problem. When the queue is full, reject new work visibly.<br>- Cap prefetch and concurrency. Consumers should take only as much work as they can handle, as with RabbitMQ’s prefetch limit. Pull based systems, such as Kafka consumers or SQS pollers, are naturally paced by the consumer.<br>- Use streams that respect pressure. Node.js streams, for example, signal when a buffer is full so the producer pauses. Reactive Streams libraries on the JVM and .NET include backpressure for the same reason.<br>- Set timeouts at every hop, and pass deadlines along, so a request nobody is waiting for any more does not keep consuming resources.</p><p>Good manners for clients<br>The best limit is one that clients respect:<br>- Obey Retry-After.<br>- Use exponential backoff with jitter, so clients do not return together.<br>- Keep a retry budget. If every failed request is retried three times, your load can multiply by four at exactly the moment you are weakest.<br>- Use circuit breakers to stop calling something that is clearly down.</p><p>Rolling out limits safely<br>Stripe’s own lessons are a good checklist:<br>- Hook limiters into the middleware stack safely, so a bug in the limiter cannot break the API.<br>- Show clear errors to users.<br>- Build a switch to turn limiters off, for emergencies.<br>- Dark launch each limiter, which means running it in log-only mode first to see what it would have blocked before it blocks anything.<br>- Design the limiter so its own failure does not take the API down. Decide in advance what happens if the counter store, such as Redis, is unreachable. Usually, fail open for ordinary traffic, but think carefully for login and payments.</p><p>Counting across many servers<br>With many servers, the count must be shared. A common approach is a Redis based token bucket, where an atomic script updates the bucket in one step. Stripe’s published example does exactly this. Tradeoffs: a shared store adds a network call and a dependency, so some teams use approximate local limits that sync periodically. Use server time for refills, not client clocks, and remember that counters cost memory: 10 million active keys at roughly 100 bytes each is about 1 GB. (Illustrative numbers.)</p><p>In your stack<br>Check each one’s documentation for your version:<br>- ASP.NET Core: built in rate limiting middleware since .NET 7, with fixed window, sliding window, token bucket and concurrency limiters<br>- NestJS and Express: <a href="http://twitter.com/nestjs/throttler">@nestjs/throttler</a> and express-rate-limit<br>- Laravel: RateLimiter and the throttle middleware<br>- Django: the throttling classes in Django REST framework<br>- Spring and Java: Bucket4j and Resilience4j<br>- Go: the x/time/rate package<br>- Gateways and edge: NGINX limit_req, Envoy, Kong, AWS API Gateway and WAF rate based rules, Azure API Management policies, Cloudflare rate limiting</p><p>The scale lens<br>- Per user limits do not protect the total. 10 requests per second per user sounds safe, but 1 million users each using it is 10 million per second in theory. You need a global ceiling too.<br>- Set the ceiling from tested capacity. If 20 servers each handle 500 requests per second, your capacity is 10,000. A common starting point is to leave headroom, for example a ceiling around 70 to 80% of what a load test proves. Treat it as a starting point, not a law.<br>- Tiers and fairness. Free, standard and enterprise plans get different limits, and one noisy tenant should not starve others.<br>- Retry amplification. Three retries per failure can multiply load fourfold during an incident.<br>- Measure. Track how many requests get limited, who gets limited, and which limits are near their ceiling. A rising 429 rate is a signal, not just a statistic.<br>- Cost. Limits also cap cloud bills. OWASP’s own example describes a monthly bill jumping from about US$13 to US$8,000 because no limits or cost alerts existed.</p><p>A security note<br>Rate limiting is one of the most important security controls an API has. OWASP lists it as API4:2023, Unrestricted Resource Consumption.<br>- Login and password reset: limit by account and by IP, add progressive delays, and combine with multi factor authentication to resist brute force and credential stuffing.<br>- SMS and OTP endpoints: without limits, attackers can trigger huge numbers of messages, which costs real money.<br>- Enumeration and scraping: limit endpoints that reveal whether an account or record exists, and cap pagination sizes.<br>- GraphQL: attackers can batch many operations into one request to slip under limiters that count HTTP requests. Limit query depth and complexity, not only request counts.<br>- Payload and upload sizes: set maximums.<br>- Volumetric DDoS: application limits cannot stop floods that fill your network. Use your cloud provider’s or CDN’s DDoS protection.</p><p>The tradeoffs<br>- Limits can block legitimate users, especially on shared networks or during genuine spikes.<br>- Tight limits frustrate good customers, and loose limits protect nothing, so tune them from real traffic.<br>- Distributed counting adds a dependency and a failure mode.<br>- Load shedding means deciding which requests matter more, which is a business decision, not only a technical one.<br>- Backpressure pushes the problem to the user, so design clear messages and graceful behavior.</p><p>Tools to start with<br>- Your gateway’s or framework’s built in rate limiter, from the list above<br>- A load testing tool such as k6, to find your real capacity<br>- Log-only or dark launch mode for new limits<br>- Dashboards for 429 rates, limit utilization, queue depth and saturation<br>- Alerts for cost and abnormal usage</p><p>Saying no early is a feature, not a failure.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=441a9fb86676" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Do Not Make the User Wait: A Practical Guide to Queues and Background Jobs, and Why Your Job Will…]]></title>
            <link>https://dtechwizard.medium.com/do-not-make-the-user-wait-a-practical-guide-to-queues-and-background-jobs-and-why-your-job-will-fcf459494bbf?source=rss-612ae3cffe2d------2</link>
            <guid isPermaLink="false">https://medium.com/p/fcf459494bbf</guid>
            <category><![CDATA[backend-development]]></category>
            <category><![CDATA[reliability]]></category>
            <category><![CDATA[message-queue]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[system-design-interview]]></category>
            <dc:creator><![CDATA[Rufus Kehinde]]></dc:creator>
            <pubDate>Fri, 23 Oct 2026 21:13:39 GMT</pubDate>
            <atom:updated>2026-10-23T21:13:39.656Z</atom:updated>
            <content:encoded><![CDATA[<h3>Do Not Make the User Wait: A Practical Guide to Queues and Background Jobs, and Why Your Job Will Sometimes Run Twice</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YiHf2CTthBtlJPW4zaDmEw.jpeg" /><figcaption>Queues and Background Jobs</figcaption></figure><p>The laundry ticket<br>You drop your clothes at a laundry and the attendant hands you a ticket with a number. You do not wait while the machines run. The shop works in the background, and you come back later or check the ticket.</p><p>Now imagine a laundry with no tickets. Every customer stands at the counter until their clothes are done. On a busy Monday, the shop is full of people waiting, and nobody new can even get in.</p><p>Many apps are built like the second laundry. A user clicks “Place order,” and the server saves the order, sends an email, generates a PDF, resizes images, calls a partner’s system and updates a report, all before it answers. The user stares at a spinner for something they never needed to wait for.</p><p>The big idea is that you should do only what the user must wait for</p><p>A simple test for each step in a request: does the user need this result right now to continue?<br>- Yes: keep it in the request. For example, validating input and saving the order.<br>- No: put a message on a queue and let a background worker do it. For example, confirmation emails, PDF receipts, image and video processing, webhooks to other systems, search indexing, analytics events, bulk imports and cleanup tasks.</p><p>The request then returns quickly with a job ID. An HTTP 202 Accepted response means “received, not finished yet,” and the user can check a status page or get notified later.</p><p>A queue does not speed up a single job. What it gives you is survival:<br>- A shock absorber. A sudden spike of orders piles up in the queue instead of crashing your database.<br>- Retries. A failed job can be tried again instead of being lost.<br>- Isolation. A slow partner system delays one background job, not your whole website.</p><p>There is a catch that surprises many developers. Most queues promise at least once delivery, not exactly once. Amazon SQS standard queues guarantee that every message is delivered at least once, but in some cases it may be delivered more than once, and the documentation tells you to design your application with idempotent operations. SQS standard queues also provide only best effort ordering. SQS FIFO queues add exactly once processing, with a five minute deduplication interval for repeated sends, at the cost of different throughput characteristics.</p><p>So your handler will sometimes run twice. If it sends an email or charges a card, “twice” can hurt.</p><p>The reliability toolkit<br>1. Acknowledge after success, not before. The broker should only delete a job once the worker finishes it. If the worker crashes, the job comes back.<br>2. Make handlers idempotent. Give every job a unique key. Before doing the work, check whether that key is already done. Better still, make the work and the “done” marker part of the same database transaction where you can.<br>3. Set the visibility or lock timeout longer than the job takes. With SQS, a message becomes invisible to other consumers for the visibility timeout, 30 seconds by default and up to 12 hours. If your job takes longer, the message reappears and another worker starts a duplicate.<br>4. Retry with backoff and a limit. Wait longer after each failure, add random jitter, and stop after a fixed number of attempts.<br>5. Use a dead letter queue. Jobs that keep failing move to a separate queue, so one bad message does not cycle forever or block the others. SQS supports dead letter queues for exactly this, and it is also how you find problem messages. Then monitor and alert on it, because a dead letter queue nobody reads is a trash can.<br>6. Use separate queues for different kinds of work. A flood of slow report jobs should never delay a password reset email.<br>7. Set a timeout per job so a stuck job cannot hold a worker forever.</p><p>A small pseudo code illustration:</p><p><em>POST /reports<br> job_id = new_unique_id()<br> db.save(job_id, status = “queued”)<br> queue.send({ job_id, user_id })<br> return 202 Accepted { job_id, status_url = “/jobs/” + job_id }</em></p><p><em>worker:<br> message = queue.receive()<br> if db.is_done(message.job_id): # safe to run twice<br> ack(message); return<br> do_the_work()<br> db.mark_done(message.job_id)<br> ack(message) # acknowledge only after success</em></p><p>The dual write problem, and the outbox<br>Here is a classic trap. When an order is placed, your code saves it in the database and then publishes an “OrderPlaced” message to the broker. Those are two separate systems. If the app crashes between the two steps, you either lose the message, or, if you publish first, you send a message about an order that never saved.</p><p>The standard solution is the transactional outbox:<br>1. In one database transaction, save the order and also insert a row in an outbox table.<br>2. A separate relay process reads the outbox and publishes messages to the broker, marking them as sent.</p><p>Because the order and the outbox row commit together, either both exist or neither does. It works in any database with transactions, such as PostgreSQL, SQL Server, Oracle and Db2. In MongoDB, use multi-document transactions where available, or change streams.</p><p>You can write the relay yourself, or let change data capture do it. Debezium reads the database’s transaction log and provides connectors for PostgreSQL, SQL Server, MongoDB, Db2 and Oracle, among others. It includes an outbox event router for relational databases, and MongoDB has its own separate outbox router, so check the connector’s maturity for your database and version.</p><p>Start simple — the database as a queue<br>For modest volumes, you do not always need a separate broker. A jobs table can work as a queue if workers claim rows safely so two workers never take the same one:<br>- PostgreSQL and Oracle: SELECT … FOR UPDATE SKIP LOCKED<br>- SQL Server: UPDLOCK with the READPAST hint<br>- MongoDB: an atomic findOneAndUpdate that flips a job from “queued” to “processing”</p><p>This keeps one system to operate. The cost is more load on your primary database, and you will outgrow it at higher volumes.</p><p>Choosing a queue<br>- Message brokers such as RabbitMQ: messages are delivered to consumers and removed once acknowledged.<br>- Event logs such as Apache Kafka: messages are kept for a retention period and consumers track their own position, so they can replay history. Ordering is guaranteed within a partition.<br>- Cloud queues: Amazon SQS and SNS, Azure Service Bus and Google Pub/Sub remove most operations work.<br>- Redis based job libraries: simple and fast, with a durability tradeoff you must understand.<br>- A durable workflow engine such as Temporal: for long, multi step processes that must survive failures.</p><p>A warning if you use Redis for jobs<br>BullMQ, a popular Node.js job queue, documents that its Redis must use the noeviction memory policy, because it cannot work properly if Redis evicts keys arbitrarily. If you point a cache with an eviction policy and a job queue at the same Redis, memory pressure can silently delete jobs. Use separate Redis instances for cache and queues. BullMQ also keeps completed and failed jobs forever by default, so set automatic removal, and its job data is stored in clear text, so avoid putting sensitive data in jobs.</p><p>In your stack, check each one’s documentation for your version:<br>- Node.js and NestJS: BullMQ<br>- Python and Django: Celery, with RabbitMQ or Redis, and RQ<br>- PHP and Laravel: the built in queues, with Redis, SQS or database drivers, and Horizon for Redis queues<br>- .NET: Hangfire, MassTransit and Azure Service Bus<br>- Java and Spring: Spring AMQP, Spring Kafka and Quartz<br>- Go: Asynq, or Temporal<br>- Ruby and Rails: Sidekiq</p><p>The scale lens<br>- The backlog arithmetic. If 1 million jobs arrive in a minute and your workers process 500 per second, it takes 2,000 seconds, about 33 minutes, to clear. If jobs arrive faster than workers can process them, the queue grows without limit. Set a target for how long each type of job may wait, and size your workers for it.<br>- Alert on age, not only depth. The age of the oldest message tells you how long users are waiting. A queue with a modest length but an old oldest message is already hurting people.<br>- Scale workers on the backlog. Autoscale on queue depth or age, and cap concurrency so you do not overload the database the workers depend on.<br>- Retry storms. When a downstream system fails, every worker retries at once and makes it worse. Use backoff with jitter and a circuit breaker.<br>- Fairness. One huge tenant can fill a shared queue and starve everyone else. Use per tenant concurrency limits, priorities or separate queues.<br>- Ordering and partitions. If order matters, use a key so related messages go to the same partition or message group, and watch for hot partitions where one key gets too much traffic.<br>- Rare becomes daily. At 10 million jobs per day, a 0.01% duplicate rate is 1,000 duplicate executions every day. (Illustrative numbers.) Idempotency is not optional.</p><p>A security note<br>- Secure the broker. Use authentication, TLS and least privilege per queue, and never expose a broker or a Redis instance to the internet.<br>- Keep secrets and personal data out of messages. Pass IDs, not whole records, and look up the data when the job runs.<br>- Validate messages like any other input. Anything that can reach the queue can forge a message. Verify webhook signatures, limit payload sizes, and avoid unsafe deserialization.<br>- Protect the dead letter queue. It holds the messages that failed, often the most interesting ones, so apply access control and retention limits.<br>- Limit who can enqueue. An open endpoint that creates jobs can be abused to flood your queue, so rate limit it.<br>- Audit money related jobs. Keep a record of what ran, when and with which key.</p><p>The tradeoffs<br>- The user sees “processing” instead of an immediate result, which needs honest design: status pages, notifications and clear errors.<br>- Eventual consistency means different parts of the system can briefly disagree.<br>- Debugging is harder, because the work no longer happens inside one request. Use a correlation ID that follows the job from the request through the queue to the worker.<br>- Queues add infrastructure to run, monitor and pay for.<br>- Not everything belongs in a queue. Fast local work does not need one, and some results, such as a payment authorization decision, are needed immediately.</p><p>Tools to start with<br>1. Your broker’s dashboard, plus queue depth and oldest message age metrics<br>2. Distributed tracing with a correlation ID across request, queue and worker<br>3. A job dashboard for your library, such as Horizon for Laravel or Bull Board for BullMQ<br>4. Load tests that include worker crashes and duplicate messages<br>5. A “kill the worker mid job” test, run on purpose</p><p>A queue is a promise to do the work later. Make sure you can keep that promise twice.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fcf459494bbf" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>