<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nitin Doyal</title>
    <description>The latest articles on DEV Community by Nitin Doyal (@ashish_sankhla_fc426bb364).</description>
    <link>https://dev.to/ashish_sankhla_fc426bb364</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4175275%2Fe11f5435-3e4a-42d9-8cb0-c1b4aba7b67b.png</url>
      <title>DEV Community: Nitin Doyal</title>
      <link>https://dev.to/ashish_sankhla_fc426bb364</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashish_sankhla_fc426bb364"/>
    <language>en</language>
    <item>
      <title>Recurring Appointments Done Right: RRULE, Exceptions and the Four Cases That Break a Naive Rule</title>
      <dc:creator>Nitin Doyal</dc:creator>
      <pubDate>Sat, 10 Oct 2026 17:38:45 +0000</pubDate>
      <link>https://dev.to/ashish_sankhla_fc426bb364/recurring-appointments-done-right-rrule-exceptions-and-the-four-cases-that-break-a-naive-rule-ipo</link>
      <guid>https://dev.to/ashish_sankhla_fc426bb364/recurring-appointments-done-right-rrule-exceptions-and-the-four-cases-that-break-a-naive-rule-ipo</guid>
      <description>&lt;p&gt;"Repeat every 7 days" is one line of code and about four months of support tickets.&lt;/p&gt;

&lt;p&gt;Recurring bookings look like a scheduling feature and behave like a data-integrity feature. The moment a customer says "actually, cancel just the 14th one", you are no longer computing dates — you are maintaining a set of exceptions against a rule, and the naive model cannot express that.&lt;/p&gt;

&lt;p&gt;Here is the shape that survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four cases that break the naive rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The occurrence that doesn't exist.&lt;/strong&gt; "Every second Tuesday" or "the last Friday of the month" has no occurrence in some months. A rule engine that silently produces nothing looks fine until a customer complains that their March appointment vanished. You need an explicit answer for months with no occurrence: skip, shift to the nearest day, or hold the last one. Pick deliberately and tell the customer which one you picked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The exception.&lt;/strong&gt; A customer cancels the 14th. If you store only the rule, the 14th comes back next cycle. If you delete the rule and re-create it, you lose the history of the earlier instances. You need both: the rule, and a list of instance-level overrides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Retroactive changes.&lt;/strong&gt; Staff changes their Tuesday shift. Do the next twelve Tuesdays move? Do the past ones? Past instances should never move — they are records of what happened. Future unmodified instances move; instances with their own override stay put. This is exactly the "this / this and following / all" semantics, and it is much easier if the distinction is structural rather than a flag you retrofit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Timezone and DST.&lt;/strong&gt; "10:00 every Monday" is a wall-clock statement, not an instant. A clinic in Jodhpur doesn't shift, but a customer travelling, a remote consultation, or a DST-observing location does. Store the series' timezone, compute instants from wall-clock rules, and never store a raw UTC instant as the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model: series, rule, instances, overrides
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;series&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="n"&gt;service_id&lt;/span&gt;    &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="n"&gt;staff_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="n"&gt;timezone&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;          &lt;span class="c1"&gt;-- IANA, e.g. 'Asia/Kolkata'&lt;/span&gt;
  &lt;span class="n"&gt;rrule&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt;          &lt;span class="c1"&gt;-- the recurrence rule&lt;/span&gt;
  &lt;span class="n"&gt;starts_on&lt;/span&gt;     &lt;span class="nb"&gt;date&lt;/span&gt;
  &lt;span class="n"&gt;ends_on&lt;/span&gt;       &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;nullable&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;        &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;ended&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;series_instance&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="n"&gt;series_id&lt;/span&gt;     &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="n"&gt;starts_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&lt;/span&gt;   &lt;span class="c1"&gt;-- materialised instant&lt;/span&gt;
  &lt;span class="n"&gt;ends_at&lt;/span&gt;       &lt;span class="n"&gt;timestamptz&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;        &lt;span class="n"&gt;scheduled&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;booked&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;cancelled&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;completed&lt;/span&gt;
  &lt;span class="k"&gt;source&lt;/span&gt;        &lt;span class="k"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;override&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions do most of the work here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Materialise instances ahead.&lt;/strong&gt; Generate the next N occurrences (say 26 weeks) at creation time rather than computing them on read. Queries get simple, availability checks become ordinary range queries, and the exclusion constraint that prevents double-booking applies to instances exactly as it does to one-off bookings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Override, never mutate.&lt;/strong&gt; Cancelling one instance flips its status. Moving one instance updates that row and marks &lt;code&gt;source = 'override'&lt;/code&gt;. The rule stays authoritative for everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  "This / this and following / all"
&lt;/h2&gt;

&lt;p&gt;This is the interaction design that users judge you on. Implement it as an explicit operation rather than a UI-only concept:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This occurrence&lt;/strong&gt; — update one instance row&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This and following&lt;/strong&gt; — end the current series at the previous occurrence, spawn a new series starting at this one with the new rule&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entire series&lt;/strong&gt; — update the series and regenerate all &lt;em&gt;unmodified&lt;/em&gt; future instances; leave overridden ones alone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "this and following" case is the one people forget. It is also the one that makes an audit trail possible, because you never rewrite history — you close one series and open another.&lt;/p&gt;

&lt;h2&gt;
  
  
  Availability has to consider the rule, not just the slots
&lt;/h2&gt;

&lt;p&gt;When a customer picks a time for a new series, you are not checking one slot. You are checking whether the whole pattern is viable: staff shifts must cover every generated instance, service duration must fit, and the resource must be free each time.&lt;/p&gt;

&lt;p&gt;Two practical rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate all generated instances in one pass, and report &lt;em&gt;which&lt;/em&gt; ones fail rather than rejecting the series wholesale. "Tuesday 10:00 is unavailable on 3 of the 26 dates — here they are" is a far better message than "invalid schedule".&lt;/li&gt;
&lt;li&gt;Reserve the first instance immediately, then create the series in the same transaction. A series that exists with no reserved opening is a booking you cannot honour.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cancelling a series
&lt;/h2&gt;

&lt;p&gt;"Cancel the rest" should cancel future &lt;em&gt;unscheduled&lt;/em&gt; instances and leave completed ones intact. If the customer has already been charged or has consumed sessions, you are in billing territory — record the cancellation against the series, not by deleting rows.&lt;/p&gt;

&lt;p&gt;Also decide what happens to a cancelled series that is later reactivated. Restarting it as a new series is almost always simpler than resurrecting the old one, because the old one's instance set no longer matches its rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the boring way
&lt;/h2&gt;

&lt;p&gt;The tests that catch real bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A rule with no occurrence in a given month (last Friday of February)&lt;/li&gt;
&lt;li&gt;Cancel one instance, run the generator again, assert it does not come back&lt;/li&gt;
&lt;li&gt;"This and following" — assert the earlier instances keep their original times&lt;/li&gt;
&lt;li&gt;A DST transition inside the range — assert wall-clock time is preserved&lt;/li&gt;
&lt;li&gt;Two concurrent bookings for the same instance — assert exactly one wins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only test the happy path, you will ship the version that works until a customer needs to cancel one date.&lt;/p&gt;

&lt;h2&gt;
  
  
  The summary
&lt;/h2&gt;

&lt;p&gt;Recurring bookings are a small rules engine with an exception list, not a date loop. Store the rule and the timezone, materialise instances ahead, express every change as an override, and keep "this / this and following / all" as three distinct operations with different data effects.&lt;/p&gt;

&lt;p&gt;If you would rather not maintain any of it, &lt;a href="https://www.swiq.services/" rel="noopener noreferrer"&gt;SWIQ&lt;/a&gt; handles recurring bookings, overrides and availability for Indian clinics and salons — &lt;a href="https://www.swiq.services/landing/" rel="noopener noreferrer"&gt;see it on a free demo&lt;/a&gt;. Our guide to &lt;a href="https://www.swiq.services/blog/appointment-booking-software-with-whatsapp/" rel="noopener noreferrer"&gt;appointment booking software with WhatsApp&lt;/a&gt; covers how confirmations behave for series, and the &lt;a href="https://www.swiq.services/blog/clinic-booking-system/" rel="noopener noreferrer"&gt;clinic booking system&lt;/a&gt; guide walks through multi-provider setup.&lt;/p&gt;




</description>
      <category>api</category>
      <category>scheduling</category>
      <category>datamodeling</category>
    </item>
    <item>
      <title>Reminder Delivery That Doesn't Spam: Idempotency Keys, Quiet Hours and Honest Opt-Outs</title>
      <dc:creator>Nitin Doyal</dc:creator>
      <pubDate>Sat, 10 Oct 2026 16:17:00 +0000</pubDate>
      <link>https://dev.to/ashish_sankhla_fc426bb364/reminder-delivery-that-doesnt-spam-idempotency-keys-quiet-hours-and-honest-opt-outs-1na5</link>
      <guid>https://dev.to/ashish_sankhla_fc426bb364/reminder-delivery-that-doesnt-spam-idempotency-keys-quiet-hours-and-honest-opt-outs-1na5</guid>
      <description>&lt;p&gt;Every team that adds appointment reminders eventually ships the same bug: a patient gets the same message four times, at 11 pm, on a number that replied STOP two weeks ago.&lt;/p&gt;

&lt;p&gt;It is not a difficult problem to reason about. It is a difficult problem to &lt;em&gt;keep&lt;/em&gt; reasoning about, because reminders sit at the intersection of four systems — your scheduler, your template provider, your delivery queue and a customer's stated preferences — and each of them has a different idea of "should this be sent".&lt;/p&gt;

&lt;p&gt;Here is the shape of a reminder pipeline that survives contact with production.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Generate reminders from an immutable schedule
&lt;/h2&gt;

&lt;p&gt;The first decision: when is a reminder &lt;em&gt;created&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;Not when you send it — when you decide it should exist. Create reminder records at booking time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;reminder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="n"&gt;booking_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="n"&gt;send_at&lt;/span&gt;         &lt;span class="n"&gt;timestamptz&lt;/span&gt;    &lt;span class="c1"&gt;-- instant, computed from the business zone&lt;/span&gt;
  &lt;span class="k"&gt;template&lt;/span&gt;        &lt;span class="nb"&gt;enum&lt;/span&gt;
  &lt;span class="n"&gt;channel&lt;/span&gt;         &lt;span class="nb"&gt;enum&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;          &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;cancelled&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;skipped&lt;/span&gt;
  &lt;span class="n"&gt;idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;
  &lt;span class="n"&gt;attempt_count&lt;/span&gt;   &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storing them means the booking is the source of truth, not the cron output. Cancel the booking → cancel the reminder rows. Reschedule → regenerate from the new anchor. No orphaned messages, no reminders for appointments that no longer exist.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;idempotency_key&lt;/code&gt; is the load-bearing field. Construct it deterministically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;booking_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;send_at_epoch&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it is derived rather than random, any duplicate generation path — a retried job, a re-run after a deploy, a manual backfill — produces the same key and the unique index rejects it. This single constraint eliminates an entire category of "sent twice" reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Send through a queue, never inline
&lt;/h2&gt;

&lt;p&gt;The API request that creates a booking must never call WhatsApp. It writes the reminder rows and returns.&lt;/p&gt;

&lt;p&gt;A worker picks up &lt;code&gt;status = pending AND send_at &amp;lt;= now()&lt;/code&gt; and attempts delivery. Why this matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provider outages don't fail the booking&lt;/li&gt;
&lt;li&gt;Retries are explicit and bounded&lt;/li&gt;
&lt;li&gt;You can drain the queue in order, or prioritise same-day reminders when a provider is slow&lt;/li&gt;
&lt;li&gt;Rate limits become a queue-depth problem, not an error budget problem&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wrap each attempt in a try/catch with a bounded retry policy — say 3 attempts over 15 minutes with exponential backoff — then mark the row &lt;code&gt;skipped&lt;/code&gt; with the provider error. A permanently failing number should not be retried forever, and you need the terminal state to report on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Make the worker idempotent even if the provider isn't
&lt;/h2&gt;

&lt;p&gt;Some providers deduplicate; most do not. If your worker crashes &lt;em&gt;after&lt;/em&gt; the provider accepts the message but &lt;em&gt;before&lt;/em&gt; you mark it sent, a naive retry sends it twice.&lt;/p&gt;

&lt;p&gt;Two mitigations, use both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pass the idempotency key to the provider&lt;/strong&gt; if it supports one. Many WhatsApp and SMS APIs accept a client reference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mark before or after, with a state machine.&lt;/strong&gt; Move &lt;code&gt;pending → sending&lt;/code&gt; before the call and &lt;code&gt;sending → sent&lt;/code&gt; after. On restart, rows stuck in &lt;code&gt;sending&lt;/code&gt; are reconciled against provider delivery logs (most expose a lookup by client reference) rather than blindly retried.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Blindly retrying from &lt;code&gt;sending&lt;/code&gt; is how you get duplicate messages on the day your deploy goes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Quiet hours are a first-class rule, not a config flag
&lt;/h2&gt;

&lt;p&gt;A reminder computed for 9 am at the clinic might land at 11 pm for a customer in another timezone, or a re-run might push a batch into the night.&lt;/p&gt;

&lt;p&gt;Encode quiet hours explicitly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compute &lt;code&gt;send_at&lt;/code&gt; in the recipient's effective timezone&lt;/strong&gt;, not the business's — unless they are the same, which you should establish at booking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clamp, don't drop.&lt;/strong&gt; If a message would fall inside quiet hours, move it to the start of the next allowed window rather than discarding it. A slightly late reminder beats no reminder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never clamp past the appointment.&lt;/strong&gt; If shifting would put the message after the start time, send it immediately (if allowed) or mark it &lt;code&gt;skipped&lt;/code&gt; with a reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also decide what happens on the day of the appointment: a "you're next" message at 06:00 is worse than no message.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Opt-out is a global kill switch, checked at send time
&lt;/h2&gt;

&lt;p&gt;The single most expensive mistake is checking consent only at enqueue time.&lt;/p&gt;

&lt;p&gt;Consent can change between &lt;code&gt;send_at&lt;/code&gt; being computed and the worker firing — someone replies STOP, or unsubscribes from settings, while60 messages sit in the queue. So check &lt;strong&gt;at send time&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;opted_out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;skipped&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;opted_out&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quiet_now&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;        &lt;span class="n"&gt;reschedule&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nb"&gt;next&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;whatsapp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="n"&gt;opt_in_confirmed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;skipped&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;no_opt_in&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                              &lt;span class="n"&gt;send&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to keep straight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transactional vs marketing.&lt;/strong&gt; A booking confirmation may be permitted where a promotional offer is not. Tag every reminder with its category and apply the right rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channel-level opt-out.&lt;/strong&gt; Unsubscribing from WhatsApp should not silently disable SMS for security-style messages — unless that's what they asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record the decision.&lt;/strong&gt; &lt;code&gt;skipped&lt;/code&gt; with a reason is a report. No row is an invisible failure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compliance-wise: for WhatsApp Business, opt-in must be collected before the first message, and every template message needs a visible way out. Check the current WhatsApp Business Messaging Policy before you decide what counts as transactional.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Template changes are a deploy, not an edit
&lt;/h2&gt;

&lt;p&gt;Reminder copy lives in provider-approved templates with their own approval lifecycle. Treat a template change like a code change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Version templates. A reminder row should record which version it intended to use.&lt;/li&gt;
&lt;li&gt;Never mutate a template in place while messages are pending — you will get a mixed batch.&lt;/li&gt;
&lt;li&gt;Handle rejection. A template that fails review should fail loudly, not silently skip messages for a week.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. What to measure
&lt;/h2&gt;

&lt;p&gt;Four numbers tell you whether the pipeline is healthy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delivery rate&lt;/strong&gt; — accepted by provider ÷ attempted&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate rate&lt;/strong&gt; — should be structurally zero; if it isn't, your idempotency boundary is wrong&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipped by reason&lt;/strong&gt; — opted-out, quiet hours, invalid number, template rejected. Each one is an actionable bucket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reminder → show-up correlation&lt;/strong&gt; — the actual point of the exercise&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Watch the skipped buckets weekly. A spike in &lt;code&gt;invalid_number&lt;/code&gt; means your capture form has a bug; a spike in &lt;code&gt;quiet_hours&lt;/code&gt; means your time window is wrong for your customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The summary
&lt;/h2&gt;

&lt;p&gt;Reminders are not "send a text an hour before". They are a durable job system with a consent check, a timezone rule, a uniqueness constraint and a terminal state for every message. Generate early, enqueue, check consent at send time, idempotency-key everything, and record why each message didn't go out.&lt;/p&gt;

&lt;p&gt;If you would rather not maintain any of it, &lt;a href="https://www.swiq.services/" rel="noopener noreferrer"&gt;SWIQ&lt;/a&gt; ships booking confirmations, day-before reminders and live queue notifications for Indian clinics and salons — &lt;a href="https://www.swiq.services/landing/" rel="noopener noreferrer"&gt;see it on a free demo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For the operational side, our guide to &lt;a href="https://www.swiq.services/blog/appointment-no-show-management/" rel="noopener noreferrer"&gt;appointment no-show management&lt;/a&gt; covers timing and messaging strategy, and this overview of &lt;a href="https://www.swiq.services/blog/appointment-booking-software-with-whatsapp/" rel="noopener noreferrer"&gt;appointment booking software with WhatsApp&lt;/a&gt; compares what platforms handle for you.&lt;/p&gt;




</description>
      <category>api</category>
      <category>scheduling</category>
      <category>backenddevelopment</category>
    </item>
    <item>
      <title>Shipping an Appointment Booking Flow That Survives Patchy Networks</title>
      <dc:creator>Nitin Doyal</dc:creator>
      <pubDate>Sat, 10 Oct 2026 13:22:28 +0000</pubDate>
      <link>https://dev.to/ashish_sankhla_fc426bb364/shipping-an-appointment-booking-flow-that-survives-patchy-networks-jba</link>
      <guid>https://dev.to/ashish_sankhla_fc426bb364/shipping-an-appointment-booking-flow-that-survives-patchy-networks-jba</guid>
      <description>&lt;p&gt;Most appointment booking demos are filmed on office wifi. The production traffic is not.&lt;/p&gt;

&lt;p&gt;If you build booking flows for small businesses — clinics, salons, service counters — the network your customers are on is the one that matters: a ₹7,000 Android phone, three bars of 4G that drop to Edge inside a concrete waiting room, and a user who will abandon the form at the first spinner that outlives their patience.&lt;/p&gt;

&lt;p&gt;This is a field report on the things that actually break in an appointment booking flow, and what to do about each one.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The link has to open, not install
&lt;/h3&gt;

&lt;p&gt;The first requirement is not technical, it's commercial: &lt;strong&gt;there is no app to download.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A customer scanning a QR code at a salon door, or tapping a booking link in an Instagram bio, has decided to give you about eight seconds. An app-install interstitial converts that intent into a bounce. A web page that renders something useful in under two seconds does not.&lt;/p&gt;

&lt;p&gt;So the constraints are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server-render the first paint. Do not ship an empty &lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt; and hydrate your way to a booking form.&lt;/li&gt;
&lt;li&gt;Put the service list and the first available slots in the initial HTML, not behind a client fetch.&lt;/li&gt;
&lt;li&gt;Defer everything that is not the booking decision: analytics, chat widgets, review widgets, fonts you don't need for the first screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat the booking page like a landing page, because that is exactly what it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Design for the request that never arrives
&lt;/h3&gt;

&lt;p&gt;The failure you must handle is not a 500. It's a request that hangs for 30 seconds and then times out — and the user taps &lt;strong&gt;Confirm&lt;/strong&gt; four more times.&lt;/p&gt;

&lt;p&gt;Four things save you here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency keys.&lt;/strong&gt; Generate a key client-side per booking attempt and send it with every retry. The server stores the key with the created booking and returns the original result on replay. Without it you get four bookings for one haircut, and the fourth one is the version the staff sees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An explicit pending state.&lt;/strong&gt; "Confirming your booking…" is honest. A button that silently disables is not — users tap disabled buttons and assume the app is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single source of truth after success.&lt;/strong&gt; Once the server confirms, the client must show the &lt;em&gt;server's&lt;/em&gt; booking object, not its local optimism. Local state and server state drifting apart is how a customer turns up for an appointment that never existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A recoverable pending queue.&lt;/strong&gt; If the network dies mid-submit, stash the intent in IndexedDB with its idempotency key and retry when connectivity returns. On reconnect, the user should see "we still have your booking pending — retry?" rather than a cleared form.&lt;/p&gt;

&lt;p&gt;Test it the cheap way: open DevTools, set the network to &lt;strong&gt;Offline&lt;/strong&gt; halfway through submission, and watch what your app does. If the answer is "nothing visible," you have found your first bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Backoff that respects the user
&lt;/h3&gt;

&lt;p&gt;Naive retry loops are a self-inflicted DDoS. Three rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff with jitter.&lt;/strong&gt; 1s, 2s, 4s, 8s — plus random jitter so a thousand clients don't retry in lockstep after a blip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the attempts and fail loudly.&lt;/strong&gt; After four attempts, show a real error with a phone number. Silence after a long retry chain is the worst possible outcome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never retry a 4xx.&lt;/strong&gt; A validation error will fail identically forever. Retrying it burns the user's battery and your API quota.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same logic applies on the outbound side. If you push confirmations to WhatsApp or SMS and the provider returns a 429, back off — do not retry immediately and get your number throttled.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Notifications are a delivery problem, not a template problem
&lt;/h3&gt;

&lt;p&gt;The booking confirmation is easy. The reminder that prevents a no-show is where systems fail, because it is a scheduled job that must fire at a specific local time for a specific person.&lt;/p&gt;

&lt;p&gt;Things that bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store the timezone, not just the offset.&lt;/strong&gt; The offset changes; the timezone name does not. Store &lt;code&gt;Asia/Kolkata&lt;/code&gt; (or the business's zone) and convert at send time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule in the server's clock, not the customer's browser.&lt;/strong&gt; A phone clock that's six minutes fast should not decide whether a reminder goes out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make sends idempotent too.&lt;/strong&gt; If the reminder worker restarts mid-run, you do not want to send the same message twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the provider's message ID.&lt;/strong&gt; When a customer says "I never got the reminder," you need to distinguish &lt;em&gt;not sent&lt;/em&gt;, &lt;em&gt;sent but not delivered&lt;/em&gt;, and &lt;em&gt;delivered to a phone that was off&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful rule: the confirmation proves the booking happened; the reminder is what makes the booking happen twice (once on the calendar, once in the room).&lt;/p&gt;

&lt;h3&gt;
  
  
  5. QR codes are entry points, not decoration
&lt;/h3&gt;

&lt;p&gt;For walk-in businesses, the QR code on the door is the front door. That has a couple of implications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The QR must resolve to a URL that works &lt;strong&gt;without&lt;/strong&gt; a session, an app, or a login.&lt;/li&gt;
&lt;li&gt;It should deep-link as far as it can — straight to the service list, not the homepage.&lt;/li&gt;
&lt;li&gt;Print it large, with a fallback short URL for people who won't scan.&lt;/li&gt;
&lt;li&gt;Give it a landing state for after hours: "bookings open at 10 am" beats a broken page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small thing, big effect: make sure the URL survives a WhatsApp message unfurl and a Gmail preview, since that is where the link often travels next.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Measure the thing your user feels
&lt;/h3&gt;

&lt;p&gt;Lighthouse on desktop wifi is a comforting fiction. Measure the real path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;INP (Interaction to Next Paint)&lt;/strong&gt; on the service picker — this is the actual "is it laggy?" metric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LCP on the booking page&lt;/strong&gt; — is the first slot visible without scrolling and without a jank?&lt;/li&gt;
&lt;li&gt;Field data over lab data. CrUX or your own RUM, segmented by device, not your laptop's throttled simulation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Watch for the classic pattern: the first visit is fine because it's warm, and the second visit — after the service worker serves a stale shell — is where it falls apart. Test your cold-cache path deliberately.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Keep the happy path short
&lt;/h3&gt;

&lt;p&gt;Every field you add between "user opens link" and "booking exists" costs conversions. For a salon or a clinic, the useful minimum is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Service&lt;/li&gt;
&lt;li&gt;Time&lt;/li&gt;
&lt;li&gt;Name + phone&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Address, date of birth, insurance ID, gender, "how did you hear about us" — none of them belong in the first booking. Collect them later, or never. A phone number is also your recovery path when a no-show is about to happen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this lands
&lt;/h3&gt;

&lt;p&gt;None of this is exotic. It is the accumulation of small refusals: refusing the spinner, refusing the silent retry, assuming the network will fail and making that failure visible and recoverable.&lt;/p&gt;

&lt;p&gt;Do that and the flow works on the phone your customer actually owns, not the one in your demo video.&lt;/p&gt;

&lt;p&gt;If you want to see the whole thing assembled — QR entry, live queue, WhatsApp confirmations and reminders — &lt;a href="https://www.swiq.services/" rel="noopener noreferrer"&gt;SWIQ&lt;/a&gt; runs it as a product for Indian clinics, salons and service counters, and you can &lt;a href="https://www.swiq.services/landing/" rel="noopener noreferrer"&gt;try a free demo&lt;/a&gt; with no card.&lt;/p&gt;

&lt;p&gt;For the wider context, this piece on &lt;a href="https://www.swiq.services/blog/appointment-booking-software-with-whatsapp/" rel="noopener noreferrer"&gt;appointment booking software with WhatsApp&lt;/a&gt; covers the notification workflows in more depth, and this comparison of &lt;a href="https://www.swiq.services/blog/appointment-booking-app-india-2027/" rel="noopener noreferrer"&gt;appointment booking apps in India&lt;/a&gt; covers the platform landscape.&lt;/p&gt;




</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Designing a Real-Time Token Queue: What We Learned Building Appointment Systems</title>
      <dc:creator>Nitin Doyal</dc:creator>
      <pubDate>Sat, 10 Oct 2026 12:56:32 +0000</pubDate>
      <link>https://dev.to/ashish_sankhla_fc426bb364/designing-a-real-time-token-queue-what-we-learned-building-appointment-systems-3646</link>
      <guid>https://dev.to/ashish_sankhla_fc426bb364/designing-a-real-time-token-queue-what-we-learned-building-appointment-systems-3646</guid>
      <description>&lt;p&gt;A token queue looks like a trivial problem until you build one. It is a sorted list with a counter — surely the simplest thing you can ship?&lt;/p&gt;

&lt;p&gt;It is not. The failure modes are all in the edges: two people joining in the same second, a staff member serving out of order, a phone that reconnects after four minutes underground, and a customer staring at a number that has not moved in eleven minutes wondering if it is broken.&lt;/p&gt;

&lt;p&gt;We have spent time working through how token-based queue and appointment systems behave in production for Indian clinics, salons and service counters. Here is what actually matters when you design one.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The queue is not a list — it is an event log
&lt;/h3&gt;

&lt;p&gt;The tempting model is an array of tokens with a &lt;code&gt;currentIndex&lt;/code&gt;. Do not do it.&lt;/p&gt;

&lt;p&gt;If your source of truth is "the current position", you cannot answer the questions that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How long did this customer actually wait?&lt;/li&gt;
&lt;li&gt;Which counter is the bottleneck?&lt;/li&gt;
&lt;li&gt;How many joined and never got served? (Your walk-away rate.)&lt;/li&gt;
&lt;li&gt;What was the queue state at 4:12 pm last Tuesday?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model every transition as an event instead: &lt;code&gt;joined&lt;/code&gt;, &lt;code&gt;called&lt;/code&gt;, &lt;code&gt;served&lt;/code&gt;, &lt;code&gt;skipped&lt;/code&gt;, &lt;code&gt;left&lt;/code&gt;. The current position becomes a fold over the log. Analytics become a query over the log. Disputes become a lookup.&lt;/p&gt;

&lt;p&gt;This is the same reason billing systems keep ledgers rather than balances. You will be asked "why was I skipped?" and you need a defensible answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;QueueEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;joined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="nl"&gt;tokenId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;qr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;counter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;called&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="nl"&gt;tokenId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;counterId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;served&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="nl"&gt;tokenId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;counterId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;tokenId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="nl"&gt;tokenId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Ordering must be decided at join time, not read time
&lt;/h3&gt;

&lt;p&gt;The classic race: two clients hit &lt;code&gt;POST /queue/join&lt;/code&gt; concurrently, both read &lt;code&gt;max(position)&lt;/code&gt;, both write &lt;code&gt;max + 1&lt;/code&gt;. One customer's place silently disappears.&lt;/p&gt;

&lt;p&gt;Fix it at the database, not in application code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A serialised counter row (&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;), or&lt;/li&gt;
&lt;li&gt;A monotonic sequence / &lt;code&gt;SERIAL&lt;/code&gt; column with the position derived from it, or&lt;/li&gt;
&lt;li&gt;An append-only table where &lt;code&gt;position&lt;/code&gt; is assigned by the insert transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you choose, the invariant is: &lt;strong&gt;position is assigned atomically with the join&lt;/strong&gt;, and no read path ever computes it. Under load, the bug appears maybe one shift in fifty — which is exactly the frequency that destroys trust in the product.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Real-time delivery needs a fallback that is boring
&lt;/h3&gt;

&lt;p&gt;Push updates (WebSocket or SSE) make the customer's screen feel alive. They also fail constantly on mobile networks.&lt;/p&gt;

&lt;p&gt;Design for it from the start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot first, then deltas.&lt;/strong&gt; On connect, send the full state. Only then stream changes. A client that reconnects mid-queue must be able to rebuild without replaying history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sequence numbers on every message.&lt;/strong&gt; Gap detected → refetch snapshot. Do not try to be clever about resuming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polling as a first-class path.&lt;/strong&gt; A 15-second poll is an acceptable degraded mode. Many customers are on patchy 4G; the experience must not depend on a persistent socket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeats with a short timeout.&lt;/strong&gt; Detect dead sockets quickly so you stop counting phantom viewers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honest note: for most counter queues, a well-built 10-second poll outperforms a fragile WebSocket implementation. Ship the poll, add push when you have measured the need.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Serving out of order is a feature, not a bug
&lt;/h3&gt;

&lt;p&gt;Reality at any counter: token 14 is a no-show, token 15 has to leave for a school pickup, and the receptionist serves token 17 because it is a five-minute form fill.&lt;/p&gt;

&lt;p&gt;A rigid FIFO queue forces staff into workarounds — which means the software stops matching reality within a day.&lt;/p&gt;

&lt;p&gt;Support explicit, auditable transitions instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;skip&lt;/code&gt; (customer not responding) with a required reason&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;recall&lt;/code&gt; (bring them back)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;priority&lt;/code&gt; (genuine cases: accessibility, emergencies at a clinic)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reorder&lt;/code&gt; with a permission check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these must be an event with a timestamp and an actor. If you cannot reconstruct why someone was served out of order, you will lose the argument at the counter.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Merge booked appointments with the walk-in queue
&lt;/h3&gt;

&lt;p&gt;Booking systems and walk-in queues are usually built as two separate features. In practice they are one problem.&lt;/p&gt;

&lt;p&gt;A customer who booked 3:00 pm and a customer who walked in at 2:55 pm are competing for the same chair. If you keep two lists, your double-book rate climbs until staff start managing on paper again.&lt;/p&gt;

&lt;p&gt;The workable model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Booked appointments occupy &lt;strong&gt;slots&lt;/strong&gt; on a resource (a stylist, a room, a counter).&lt;/li&gt;
&lt;li&gt;Walk-ins fill &lt;strong&gt;gaps&lt;/strong&gt; between slots, estimated by real historical service duration.&lt;/li&gt;
&lt;li&gt;Late arrivals degrade into the walk-in queue rather than silently breaking the slot.&lt;/li&gt;
&lt;li&gt;One ordered view is presented to staff; customers only see their own position and estimate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point matters: customers do not need global ordering, they need an honest estimate and a signal when they are close.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Waiting time beats position as the UX
&lt;/h3&gt;

&lt;p&gt;"you are 7th" is a weak message. Seven at a quiet clinic is four minutes; seven on a Monday morning is forty.&lt;/p&gt;

&lt;p&gt;Two improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Show an estimate with a range&lt;/strong&gt; — "about 20–30 minutes" — and refresh it as observed service times change. Ranges survive variance; precise numbers do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notify on distance, not on position&lt;/strong&gt; — "your turn is next" when they are 2 away, so they can come back from the car park.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never promise a time you cannot hit. An estimate that runs long is worse than no estimate at all, because it converts a patient wait into a complaint.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Analytics is the real product
&lt;/h3&gt;

&lt;p&gt;The token display is what the customer sees. The reporting is what the business owner pays for.&lt;/p&gt;

&lt;p&gt;Worth having from day one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Median wait by hour&lt;/td&gt;
&lt;td&gt;Flat the peaks; staff to the real load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service time p50 / p95 per staff&lt;/td&gt;
&lt;td&gt;Separates slow service from slow systems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Joined vs served&lt;/td&gt;
&lt;td&gt;Your walk-away rate — the money leak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Booking share vs walk-in&lt;/td&gt;
&lt;td&gt;Whether the link is actually working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No-show rate&lt;/td&gt;
&lt;td&gt;Whether reminders are earning their cost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;p95 matters more than the mean. One 40-minute colour service will drag the average and hide the fact that everything else ran at 12 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Operational details nobody thinks about
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys on join.&lt;/strong&gt; Mobile connections retry. Duplicate tokens are embarrassing and common.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timezone in storage and in display.&lt;/strong&gt; Store UTC, render in the business's timezone, and show it. Indian businesses operate IST; daylight-saving bugs are rare here but off-by-one-day bugs are not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline-tolerant staff UI.&lt;/strong&gt; The receptionist's screen must keep working through a 30-second drop, then reconcile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A kill switch per counter.&lt;/strong&gt; Sometimes you just need to pause intake because the doctor stepped out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;The queue itself is easy. The hard parts are concurrency, honesty about time, and letting staff break the order without breaking the data.&lt;/p&gt;

&lt;p&gt;Get those three right and the system survives contact with a busy Saturday — which is the only benchmark that counts.&lt;/p&gt;

&lt;p&gt;If you want to see a production version of this in action, &lt;a href="https://www.swiq.services/" rel="noopener noreferrer"&gt;SWIQ&lt;/a&gt; runs a queue and booking system built for Indian clinics, salons and service counters — live token tracking, a QR booking page, and WhatsApp turn alerts, with a free demo at &lt;a href="https://www.swiq.services/landing/" rel="noopener noreferrer"&gt;swiq.services/landing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Further reading: this guide to &lt;a href="https://www.swiq.services/blog/appointment-booking-with-live-token/" rel="noopener noreferrer"&gt;appointment booking with live tokens&lt;/a&gt; covers the customer-facing flow, and this &lt;a href="https://www.swiq.services/blog/queue-management-system/" rel="noopener noreferrer"&gt;queue management system&lt;/a&gt; guide covers the operational side.&lt;/p&gt;




</description>
      <category>architectuarchitectureebdev</category>
    </item>
  </channel>
</rss>
