How GimBooks Scaled GST Filing to 3M Users Without a DBA
For: A fintech founder or CTO at a pre-Series A Indian SaaS company whose accounting or compliance product is growing fast but whose backend was designed for hundreds of concurrent filers, not hundreds of thousands — and is now buckling every GST deadline cycle
The bottleneck in deadline-driven compliance SaaS is almost never CPU or database throughput. It's the synchronous coupling between a user clicking "file return" and a third-party government API responding — an API you don't control, that rate-limits you, and that gets slower the closer you are to the deadline. GimBooks learned this scaling its GST filing product to millions of SMB users across India. Throwing more servers at it made the bill go up and the reliability go down. What actually worked was making the submission layer fully asynchronous, resumable, and idempotent — and treating the GSTN API as a flaky external dependency instead of an in-request call.
This post walks through what CodeNicely and the GimBooks engineering team ran into, what we tried first that didn't work, and the architectural decisions that let the product survive filing deadlines without a dedicated DBA babysitting the database at 11:47 PM on the 20th of every month.
Context: what GimBooks actually does
GimBooks is a YC-backed accounting and compliance SaaS built for Indian SMBs — kirana stores, small manufacturers, service businesses, freelancers. The core use case is invoicing plus GST return filing (GSTR-1, GSTR-3B, and related forms). Users prepare invoices through the month, then file returns before statutory deadlines — the 11th, 20th, and a handful of quarterly dates.
The traffic shape is brutal. For roughly 25 days a month, the system sees steady, predictable load. Then a 48-hour window before each deadline concentrates a huge fraction of monthly submission volume into a narrow band, and the last six hours before cutoff are worse still. SMB owners are not going to file on the 5th of the month. They file at 9 PM on the 20th, alongside everyone else, often after their CA has told them to.
You can read the full engagement summary on the GimBooks case study. This post focuses on the scaling story specifically.
The original architecture (and why it worked for 50K users)
The initial backend was a fairly standard Django monolith with PostgreSQL, Redis for caching, and a small Celery pool for background jobs. Filing a return worked like this:
- User clicks "File GSTR-3B."
- API handler validates the return, computes tax, generates the JSON payload.
- Same handler calls the GSTN (Goods and Services Tax Network) API to submit.
- Handler waits for ARN (Acknowledgement Reference Number), writes it to the DB, returns success to the user.
For a few hundred concurrent filers this is fine. The handler blocks for a few seconds, the GSTN API responds, everyone goes home. It's simple, debuggable, and the user sees an instant confirmation.
It also has three properties that make it fail catastrophically under burst load:
- Synchronous coupling to a third-party API. Every in-flight filing holds a gunicorn worker hostage until GSTN responds. On deadline day, GSTN itself slows down — response times drift from 800ms to 15+ seconds.
- No idempotency. If the user retried (they always retry), the system had no reliable way to know whether the previous attempt actually reached GSTN or died in transit.
- Long-running DB transactions. Because writes were interleaved with the external call, connections sat open for the duration of the GSTN round-trip, exhausting the connection pool.
What we tried first (and why it didn't fix the problem)
The instinct — and the first thing most teams try — is to scale horizontally. That's what we did too. Here's what happened.
Attempt 1: More application servers
We doubled the gunicorn pool, then quadrupled it. Filing throughput improved marginally. Then Postgres started dropping connections because the connection count exploded. We added PgBouncer in transaction pooling mode. That helped for a week. Then during the March 20 filing window, response times spiked to 40+ seconds and a chunk of filings timed out with unclear state — had GSTN received them or not?
Lesson: horizontal app-tier scaling doesn't help when every request is blocked on a downstream API you don't control. You're just buying more expensive waiting rooms.
Attempt 2: Read replicas and query tuning
We assumed the DB was the problem. Added a read replica, moved reporting queries off primary, indexed everything the slow query log complained about. This was worth doing — query p99 dropped meaningfully — but it didn't move the needle on filing failures, because filing failures were never really about query time. They were about workers held open waiting for GSTN.
Attempt 3: Aggressive request timeouts
We shortened the GSTN call timeout to 5 seconds and started returning "try again" to the user on timeout. This made the dashboards look better and made the actual user experience worse. Users retried, sometimes multiple times, and we started seeing duplicate ARN attempts on the same return — a compliance nightmare, because GSTN treats duplicate submissions unpredictably.
At this point it was clear that no amount of infrastructure tuning was going to fix a design where the user's HTTP request was tied to a slow, flaky, externally-rate-limited API call.
The architectural call that actually worked
The fix was conceptually simple and operationally invasive: decouple submission from the user request entirely, and treat every filing as a durable, resumable state machine.
The new submission flow
- User clicks "File GSTR-3B."
- API handler validates the return, computes tax, generates the payload, writes a
filing_jobrow with state =QUEUEDand an idempotency key derived from (user_id, return_period, form_type). Returns to the user immediately with a job ID. - User's UI moves to a "Filing in progress" state with a live status feed (server-sent events).
- A dedicated worker pool picks up
QUEUEDjobs, transitions them toSUBMITTING, calls GSTN, and transitions toACKNOWLEDGED(with ARN) orFAILED(with a typed error). - On any transient failure — network, 5xx, rate limit — the job goes back to
QUEUEDwith an exponential backoff and a retry counter. The idempotency key prevents double-submission if a previous attempt actually reached GSTN.
This is not a novel pattern. It's the standard "outbox + worker" shape you'd use for any external side-effect. The interesting part is what it forced us to fix along the way.
Idempotency against a system that doesn't fully support it
GSTN's API surface is not designed with client-side idempotency keys the way Stripe's is. If you submit twice and the first one succeeded, you may or may not get a clean duplicate error — behavior varies by form and by time of day. So we built a two-phase check: before every submission attempt, the worker queries GSTN for the current return status. If a return for that period is already filed, we mark the job ACKNOWLEDGED using the ARN from the status response and skip resubmission. Only if the status confirms "not filed" does the worker actually submit.
This adds one extra API call per attempt. It's worth it. The cost of a duplicate filing — reconciling it, explaining it to a user's CA, potentially amending — is orders of magnitude higher than an extra status check.
Queue partitioning by deadline pressure
A naive single queue treats all jobs equally. In reality, a GSTR-1 filing due tomorrow is not the same priority as a draft save from a user filing early. We partitioned the queue by time-to-deadline, with three tiers: routine (>24h to deadline), pressured (2–24h), and critical (<2h). Each tier has its own worker pool with different concurrency and retry policies. Critical jobs get more aggressive retries and dedicated workers so a spike in routine jobs cannot starve deadline-critical ones.
Backpressure toward GSTN, not toward users
GSTN rate-limits per GSP (GST Suvidha Provider) connection. Instead of letting workers hammer the API and get throttled, we put a token-bucket rate limiter in front of the outbound call, sized to the GSP's known ceiling with a safety margin. When the bucket empties, workers wait — but the user is not waiting, because the user got a job ID and closed the tab an hour ago. Backpressure is absorbed by the queue, not by the human.
Data model changes
The original schema had one gst_return row per (user, period, form) and mutated it in place. We added an append-only filing_attempt table — every submission attempt is a row, with request payload hash, response, timestamps, and worker ID. The gst_return row now points to the current successful attempt. This gave us three things: an audit trail for regulatory disputes, the ability to replay failed submissions offline, and a clean way to distinguish "we never tried" from "we tried and GSTN said no."
What moved and what didn't
The re-architecture was not free and the metrics that improved were not the ones we initially expected.
What got dramatically better:
- Deadline-day p95 API response time dropped to near-normal, because the user-facing API no longer waited on GSTN. It just wrote a job and returned.
- Duplicate submission incidents essentially stopped, once the pre-check + idempotency key pattern was live.
- Postgres connection pool saturation stopped being the recurring outage cause.
- Support tickets shaped like "is my return filed or not?" dropped sharply, because the status feed gave users a real-time view instead of a spinning wheel and a timeout.
What got worse or stayed hard:
- Total submission latency (click to ARN) is higher on average than before, because of the extra hop through the queue. Users don't experience it as latency, but it is latency.
- The system is meaningfully more complex to operate. You now need dashboards for queue depth, worker lag, retry rates, and GSTN error type distributions. Debugging a single stuck filing takes more steps.
- GSTN itself is still the ceiling. When GSTN is down, we can queue and hold, but we cannot file. The best we can do is be ready to burst-submit the moment they recover, and communicate honestly to users.
How we got rid of the "we need a DBA" problem
The title claim is worth defending. GimBooks does not have a full-time DBA. What replaced that role was a combination of things:
- Managed Postgres (with automated backups, PITR, and version upgrades handled by the provider).
- Aggressive schema hygiene — every migration reviewed for lock behavior, using
CREATE INDEX CONCURRENTLY, avoiding rewrites on large tables. - Moving the highest-write-rate tables (filing attempts, audit logs, event streams) off the primary transactional DB and into append-optimized stores.
- Query budgets in code review: any new endpoint on a hot path had to justify its query count and index usage before merging.
- Alerting on leading indicators (queue depth, replication lag, connection pool utilization) rather than lagging ones (5xx rate).
None of this is exotic. It's discipline plus the right managed services. The trap teams fall into is assuming they need a DBA when what they actually need is fewer places in the code that can generate pathological queries.
What generalizes to other compliance and fintech SaaS
If you are building a GST filing app architecture, TDS filing, e-invoicing, ROC compliance, or any product with deadline-driven bursts against a government or regulator API, the pattern is the same:
- Never call the regulator API from a user-facing request handler. Always queue. The user gets a job ID, not an ARN.
- Idempotency is your problem, not the regulator's. Assume the API has partial failures and duplicate-detection gaps. Build a pre-check + attempt log.
- Partition your queue by deadline pressure. All jobs are not equal in the last 2 hours.
- Rate-limit outbound, not inbound. Let the queue absorb bursts. Do not throttle your own users to protect the regulator — throttle your workers.
- Design the data model to be append-only where audit matters. Every submission attempt is evidence. Mutating in place loses information you will one day need.
- Instrument the leading indicators. Queue depth and worker lag tell you a deadline is going to hurt 30 minutes before your 5xx dashboard notices.
The larger lesson for fintech SaaS scaling in India: the constraint is almost always a shared external system — GSTN, NPCI, a bank's IMPS endpoint, a KYC provider, an account aggregator. Your architecture's job is to insulate your users from that system's bad days. Horizontal scaling of your own tier does not do that. Decoupling does.
How CodeNicely can help
If your compliance or accounting SaaS is buckling on deadline cycles, the fix is rarely more servers. It is usually a submission-layer redesign of the kind described above. That's the work CodeNicely did with GimBooks — the details are in the GimBooks case study — and it's the same pattern we've applied in other bursty-load environments, including the KYC and disbursal flows in CashPo and the deadline-sensitive dispatch scheduling in Vahak.
What tends to match your situation specifically: a pre-Series A team with a monolith that was correct for the first 50K users, a founding engineer who knows the system needs to be pulled apart but cannot stop shipping features to do it, and a product where a bad deadline day is a customer-trust event, not just a metrics event. We work as an embedded team — not a body-shop — take full IP handover, and stay engaged through the deadline cycles where it actually matters. More on how we work with scale-up engineering teams.
Frequently Asked Questions
Why does a GST filing app crash on deadline days but not otherwise?
Because filing traffic is bursty and synchronously coupled to the GSTN API, which itself slows down under load. Application servers hold worker processes open waiting for GSTN responses, exhausting connection pools and blocking new requests. Normal-day traffic doesn't stress this coupling; deadline-day traffic does.
Will moving to Kubernetes or a bigger database instance fix the problem?
Usually not on its own. If your submission layer is synchronously coupled to an external regulator API, adding more application capacity just means more workers waiting on the same slow downstream. You often need to decouple submission into an async, resumable job queue before infrastructure scaling produces returns.
How do you handle idempotency when the government API doesn't support client-side idempotency keys?
Two-phase check: before submitting, query the API for current filing status for that period and form. If already filed, record the existing ARN and skip. Only submit if the status confirms not-filed. Combine this with an internal idempotency key on (user, period, form) so retries do not spawn duplicate attempts.
Do we need a dedicated DBA to run a multi-tenant accounting SaaS at scale?
Not usually. What you need is a managed Postgres offering, disciplined migration and query-review practices, high-write tables moved off the transactional primary, and alerting on leading indicators like queue depth and replication lag. A DBA becomes necessary at much larger scales or when you're running self-managed clusters.
How long does it take to re-architect a synchronous filing flow into an async one?
It depends heavily on your current data model, test coverage, and how much of the submission logic is entangled with request handlers. Contact CodeNicely for a personalized assessment based on your codebase and deadline cycle.
Building something in Fintech?
CodeNicely partners with founders and tech teams to ship AI-native products that move metrics. Tell us about the problem you're solving.
Talk to our team_1751731246795-BygAaJJK.png)