Choosing a PDF Badge Routing Strategy Under Event Load
Symptom Statement Link to this section
The generator is fine and the badges are correct, but delivery falls apart at scale: a pre-event email blast bounces or lands in spam so attendees arrive with nothing, a presigned link expires between generation and the door, or an on-site spooler that was never load-tested chokes when 3,000 people show up in the first hour. The common thread is not a bug in any one transport — it is that the routing strategy was chosen for a load profile the event does not actually have. A plan tuned for a calm pre-event mailout behaves very differently from one that must print physical badges at a peak-hour check-in. This is the delivery layer owned by PDF routing workflows, and this page frames a single decision: how to pick among email delivery, presigned links, direct-to-print, and a hybrid so the strategy matches when and how badges are consumed. The failure this prevents is picking the wrong strategy for your load profile — a choice that looks fine in a rehearsal of fifty and produces a queue of a thousand on the day.
Root Cause Analysis Link to this section
Picking the wrong strategy is not one mistake but a mismatch between four load-profile factors and the transport’s real behaviour. Each factor, read wrong, produces a distinct day-of failure.
- Consumption timing read wrong. A strategy tuned for pre-event mailout (email) is applied to an on-site event where badges must be physical and immediate, so attendees arrive holding a PDF and no printed badge and the desk becomes the printer of last resort.
- Blast radius underestimated. A single shared transport — one SES sending identity, one spooler, one presigning key — becomes a single point of failure. When it degrades, every undelivered badge fails together instead of a bounded subset, turning a partial incident into a total outage.
- Retry semantics mismatched. Email retries are provider-driven and slow (minutes to hours); a spooler retry is a fast requeue; a presigned link does not retry at all once it expires. Choosing a transport whose retry rhythm does not match the SLA means failures are discovered too late to recover on the day.
- Cost and connectivity ignored. A presigned-link or email plan assumes reliable venue connectivity; a flaky hall breaks it. A direct-spool plan assumes local hardware capacity; an under-provisioned printer fleet breaks it. The right strategy is the one whose assumptions match the room.
Strategy Comparison Link to this section
The four strategies are not ranked — each is correct for a different load profile. Read the table by your own constraints: when badges are consumed, how bad a single-transport failure would be, how fast you can retry, what you can spend, and whether the room is online.
| Strategy | Latency to attendee | Failure blast radius | Retry semantics | Cost | Best fit |
|---|---|---|---|---|---|
| Email via AWS SES | Minutes to hours; async | Per-recipient; a bounce isolates to one attendee, but a reputation/identity block hits all | Provider-driven with a DLQ; slow (minutes–hours) | Low per-message; no on-site hardware | Pre-event, attendees self-print |
| Presigned S3 links | Seconds to generate; delivery decoupled | Per-link; expiry or a bad key can invalidate a whole batch of links at once | Regenerate the link; no in-flight retry | Very low storage/egress | Pre-event or hybrid; large files, archival, kiosk fetch |
| Direct-to-print spooler | Sub-second to the printer | Per-printer/venue; a spooler stall blocks everyone behind it | Fast requeue; owned by the spooler | Printer fleet + on-site staff | On-site, tight latency, physical badge now |
| Hybrid | Pre-event async + on-site sub-second | Smallest; a failure in one path fails over to another | Layered: email DLQ, link regen, spool requeue | Highest — runs two paths | Multi-day or mixed events needing both |
Email via SES Link to this section
Best when badges are consumed before the attendee arrives and they can print or display their own. Delivery is asynchronous and the blast radius is naturally per-recipient, but it depends on sender reputation and inbox placement, so it is the wrong sole strategy for a same-day, on-site crowd. The provider-side implementation and its DLQ live in automating PDF badge delivery via AWS SES.
Presigned S3 Links Link to this section
Best when the file is large, when you want an archival URL, or when a kiosk or app fetches the badge on demand. Generation is cheap and delivery is decoupled from storage, but a presigned link has a hard expiry and no in-flight retry — if it lapses between generation and use, it must be regenerated. It pairs well as the durable fallback inside a hybrid.
Direct-to-Print Spooler Link to this section
Best on-site, when the SLA is a physical badge in hand within seconds. Latency is the lowest of any strategy and retry is a fast local requeue, but the blast radius is the printer or venue: one stalled spooler blocks everyone behind it, which is why queue behaviour is owned by print queue orchestration.
Hybrid Link to this section
Best for multi-day or mixed events: pre-mail via SES, spool on-site, and archive to S3 so any single path can fail over to another. It has the smallest blast radius and the highest cost, because you operate and monitor two live paths at once.
Minimal Working Implementation Link to this section
A single self-contained selector: a Pydantic v2 RoutingProfile captures the load-profile inputs with validated enums, and choose_strategy maps a profile to exactly one strategy. The assert block proves each branch of the decision tree resolves to the intended choice.
from enum import Enum
from pydantic import BaseModel, ConfigDict, field_validator
class Strategy(str, Enum):
SES_EMAIL = "ses_email"
PRESIGNED_S3 = "presigned_s3"
DIRECT_SPOOL = "direct_spool"
HYBRID = "hybrid"
_CONSUMED = {"pre_event", "onsite", "both"}
_CONNECTIVITY = {"reliable", "flaky", "offline"}
_TARGET = {"attendee_inbox", "kiosk", "printer"}
class RoutingProfile(BaseModel):
"""The load-profile inputs that drive the routing decision."""
model_config = ConfigDict(strict=True, extra="forbid")
consumed: str # when badges are used: pre_event | onsite | both
connectivity: str # venue network: reliable | flaky | offline
delivery_target: str # attendee_inbox | kiosk | printer
badge_volume: int # total badges to deliver
latency_sla_s: float # acceptable seconds from request to delivery
@field_validator("consumed")
@classmethod
def _v_consumed(cls, v: str) -> str:
if v not in _CONSUMED:
raise ValueError(f"consumed must be one of {_CONSUMED}")
return v
@field_validator("connectivity")
@classmethod
def _v_conn(cls, v: str) -> str:
if v not in _CONNECTIVITY:
raise ValueError(f"connectivity must be one of {_CONNECTIVITY}")
return v
@field_validator("delivery_target")
@classmethod
def _v_target(cls, v: str) -> str:
if v not in _TARGET:
raise ValueError(f"delivery_target must be one of {_TARGET}")
return v
def choose_strategy(p: RoutingProfile) -> Strategy:
"""Map a load profile onto exactly one routing strategy."""
# Mixed consumption always wants layered fallback.
if p.consumed == "both":
return Strategy.HYBRID
if p.consumed == "pre_event":
# Self-print via inbox unless files are large/archival or volume is huge.
if p.delivery_target == "attendee_inbox" and p.badge_volume <= 20_000:
return Strategy.SES_EMAIL
return Strategy.PRESIGNED_S3
# on-site
if p.connectivity == "offline":
return Strategy.HYBRID # local spool + cloud archive fallback
if p.connectivity == "flaky":
return Strategy.HYBRID
if p.delivery_target == "printer" and p.latency_sla_s <= 5:
return Strategy.DIRECT_SPOOL
return Strategy.DIRECT_SPOOL
# --- Verification: every branch of the tree resolves as intended ------------
if __name__ == "__main__":
pre_mail = RoutingProfile(consumed="pre_event", connectivity="reliable",
delivery_target="attendee_inbox",
badge_volume=8_000, latency_sla_s=3600)
assert choose_strategy(pre_mail) is Strategy.SES_EMAIL
pre_bulk = RoutingProfile(consumed="pre_event", connectivity="reliable",
delivery_target="kiosk",
badge_volume=50_000, latency_sla_s=60)
assert choose_strategy(pre_bulk) is Strategy.PRESIGNED_S3
onsite_fast = RoutingProfile(consumed="onsite", connectivity="reliable",
delivery_target="printer",
badge_volume=5_000, latency_sla_s=2)
assert choose_strategy(onsite_fast) is Strategy.DIRECT_SPOOL
onsite_flaky = RoutingProfile(consumed="onsite", connectivity="flaky",
delivery_target="printer",
badge_volume=5_000, latency_sla_s=2)
assert choose_strategy(onsite_flaky) is Strategy.HYBRID
mixed = RoutingProfile(consumed="both", connectivity="reliable",
delivery_target="printer",
badge_volume=12_000, latency_sla_s=2)
assert choose_strategy(mixed) is Strategy.HYBRID
print("OK — all five load profiles resolved to the intended strategy")
The assert block is the decision’s own regression test: it drives one profile through each leaf of the tree and pins the resolved strategy, so a future edit to choose_strategy that misroutes a profile fails loudly instead of shipping the wrong plan to an event.
Memory & Performance Constraints Link to this section
The selector is trivial to run; the constraints that bite are in the transports it chooses and in the batch that feeds them.
| Component | Constraint | Mitigation |
|---|---|---|
| SES send rate | A per-second send quota throttles a large pre-event blast | Meter dispatch against the quota; queue overflow and drain via async batch processing |
| Presigned link lifetime | A short expiry lapses before use; a long one leaks access | Set expiry to the consumption window; regenerate on demand rather than pre-minting far ahead |
| Spooler queue depth | A peak-hour surge outruns printer throughput | Load-balance jobs across printers and shed to fallback per print queue orchestration |
| PDF size in transit | Large embedded assets inflate email and egress | Subset fonts and compress before routing; keep the payload lean upstream |
| Hybrid state | Running two paths doubles the state to reconcile | Key every delivery on a single registration_id so the paths dedupe against one ledger |
Incident Triage & Rollback Link to this section
Fast path when a chosen strategy is failing under real load — target under 15 minutes to a working fallback.
- Name the failing path. Check which transport is erroring:
curl -s localhost:9090/metrics | grep -E 'route_(ses|s3|spool)_fail_total'. A single hot counter localizes the blast radius. - Confirm the profile matches reality. Re-run
choose_strategyagainst the actual day-of profile (connectivity, volume, timing). A strategy chosen from a stale profile is the usual root cause. - Fail over, do not fix in place. Switch the live strategy to the fallback the hybrid already defines — for on-site, flip from direct spool to presigned links attendees can fetch at a kiosk; for pre-event, regenerate expired links.
- Re-drive undelivered badges. Replay the undelivered set through the new path; because every delivery keys on
registration_id, a replay can only re-deliver a missing badge, never duplicate one already handed out.
Rollback. Flip the strategy flag back and redeploy: ROUTING_STRATEGY=hybrid then git revert HEAD~1 --no-edit && docker compose up -d --build. The selector is pure, so reverting reproduces the prior routing decision exactly.
Post-rollback validation. Confirm the selector and the live counters agree before reopening:
python -m badge.routing # runs the __main__ asserts; expect the OK line
Frequently Asked Questions Link to this section
Should I just always run the hybrid strategy to be safe? Only if you genuinely consume badges both before and during the event. Hybrid has the smallest blast radius but the highest operational cost — two live paths to run, monitor, and reconcile. For a purely pre-event mailout or a purely on-site print, a single well-chosen strategy is cheaper and has fewer moving parts to fail. Match the strategy to the profile, not to fear.
Presigned links or email for pre-event delivery? Email when attendees self-print from their inbox and the file is small; presigned links when the file is large, when you want an archival URL, or when a kiosk or app fetches on demand. The deciding factors are file size and whether delivery is push (email) or pull (a link the attendee or kiosk opens), not raw cost — both are inexpensive.
How do I switch strategy mid-event without double-printing?
Key every delivery on a single registration_id and dedupe against one ledger, exactly as the routing stage requires. Then a mid-event fail-over — say, from direct spool to presigned links — can only deliver a badge that has not been delivered yet; a replay of the undelivered set never re-prints one already in an attendee’s hand.
Related Link to this section
- PDF Routing Workflows — the parent stage that owns deterministic, exactly-once delivery; this page is the decision that selects which of its transports to run.
- Automating PDF Badge Delivery via AWS SES — the email transport in depth, including its dead-letter routing and retry behaviour.
- Print Queue Orchestration — the on-site queue behind the direct-spool and hybrid strategies, where peak-hour load balancing lives.
- Async Batch Processing — the durable queue and DLQ that meter dispatch and absorb overflow whichever strategy is chosen.