At 10:00 UTC on 24 August 2026, about 300 sales agents at Provident Real Estate in Dubai opened a CRM that did not exist in January. Every lead they had ever touched was there: 1.65 million of them, with 5.3 million activities, 1.14 million contacts and, a month later, 2.8 million archived calls. Bitrix24, which had run the business for years, was switched to read-only, kept alive in parallel for seven weeks, and then disconnected for good on 12 October.

This is how we did it, what broke, and what I would tell anyone about to do the same.

Why we left Bitrix24

The short version: pages took more than two minutes to load behind a spinner, and the business had outgrown what we could bolt onto it.

The longer version only became clear once we read the data. Lead distribution ran through a third-party "round robin" add-on, and reading its source showed the pick was effectively random. There was no manager escalation, so expired leads fell back to an admin account, where about 148,000 of them were sitting. The real routing rule was not in any setting. It lived in a free-text field whose nine values were, in practice, the routing decision.

The Lead Pool was built out of deal pipelines. Claiming a lead created a new one and left a stub behind, which produced about 370,000 duplicate stubs. Contacts had never been deduplicated, every custom field existed twice (once for leads, once for deals), and phone numbers were hiding in nine non-standard fields.

Around Bitrix had grown a ring of satellites: a gateway proxying its API, a microservice layer, a listings app running as an iframe inside it, WhatsApp and Telegram bots, PHP jobs polling Bayut every 30 minutes, and listing feeds published from its storage. Everything depended on Bitrix, and nothing could move faster than it.

So in January 2026 we started a new platform: NestJS, Next.js and PostgreSQL on AWS. HR, attendance and assets moved first, from March. The CRM, the hardest part, moved last.

The rule: lose nothing, and never assume "it ran"

Two principles shaped every decision.

Lose nothing. Each imported row kept its full source record, plus every non-empty custom field, in a raw JSON column. That felt like over-engineering at the time. Weeks later it let us recover 138,000 phone numbers hidden in an unmapped field, with no re-import.

Every step is re-runnable and verified. The importers are idempotent upserts keyed on the Bitrix ID, so a second run reports "created 0". They fire no side effects: imported history triggers no routing, notifications or timers. They refuse outright to run against a database named prod. And between "loaded" and "imported" there is a mandatory count gate, because, as it turned out, a green run meant very little.

How the data moved

We used database dumps, not the Bitrix REST API. Bitrix runs on MySQL. An exporter produced dump bundles grouped by entity, with checksums. We restored them into a throwaway MariaDB container, and TypeScript importers wrote them into Postgres. The live Bitrix database was never written to.

Development was the rehearsal stage for production. Between late July and the cut-over we ran at least three full-scale imports and two delta runs. The full dress rehearsal, on 1 and 2 August, loaded the complete production export in under three hours. The core import took 19 minutes. Activities, comments, emails, stage history and calls took 36, down from about two and a half hours once we split them into two parallel streams.

The import order was fixed and deliberate: contacts, leads, deals, then activities and history, then listings, files and calls. Deals go before activities because deleting a deal cascades away its timeline. Board indexes are dropped before the bulk load and rebuilt afterwards.

Files were the pleasant surprise. Rather than downloading and re-uploading, we copied the whole Bitrix file bucket server-side: 2.6 million objects, 1.15 TiB, in three and a half hours, verified by count, size and checksum. The AWS request bill for that was $14.03. Every row keeps its original file ID, and every file we could not reach went into a work list instead of being silently dropped.

Cutting over without touching a production row

The cut-over was staged rather than a single big bang. Listings moved first, on 12 August: 26,582 listings with 440,920 images, swapped in 48 seconds after a verified backup. The final Bitrix export came on 23 August. The leads, contacts and deals copy ran on the morning of the 24th, about 20.5 million rows between 09:00 and 09:45 UTC. The 2.8 million archived calls followed on 26 September.

The production copy was insert-only. Nothing in that runbook deleted or updated a production row; every write was an insert of a row production did not yet have. It ran in four phases (lookups, contacts, leads, deals), each in its own transaction, each re-verified read-only before the next began. A person approved each phase. A dry-run preflight checked 31 tables and about 17.5 million rows and refused to write if any reference lacked a parent.

The freeze window nearly broke the plan. Between the rehearsal and cut-over day, 12,297 already-migrated leads changed stage or owner and 31,918 new ones arrived, so a straight insert would have frozen stale data forever. A full refresh took about 80 minutes. A delta mode that read only changed rows (732 instead of a million) brought it to about 15. Fifteen minutes fits inside a freeze window. Eighty does not.

We also agreed a shutdown order for Bitrix in advance: stop intake first, then automations and timers, then outgoing emails, then make it read-only for users rather than just changing passwords, record the exact freeze timestamp, and leave the file bucket alone until everything was verified. The phone provider fed both systems, so no call data was lost in the gap.

Some things were deliberately left off on go-live day. Lead expiry timers and the new distribution rules stayed in shadow mode, because switching them on blind would have aged out or stranded hundreds of thousands of leads.

Almost every failure was silent

This is the part I most want other engineers to read. Very little failed loudly. Most problems produced green runs and plausible numbers.

  • The dump that erased itself. Bitrix's phone and email table ships across several dump files, each starting with DROP TABLE, so whichever loads last wins. Our development database ended up with no phone numbers or emails at all, and the importers reported success. A schema-only file would also have quietly wiped every listing after it loaded. Hence the count gate.

  • Same label, opposite meaning. Bitrix's "Transferred" is a terminal close reason; ours is a live re-routing queue. Its "Send to Lead Pool" is also terminal. That put 484,501 closed leads into our live pool, and 87% of the queue could not be claimed. Two of these three cases were caught by someone opening a board and thinking the number looked wrong, not by any check. Read the semantics, never the label.

  • Calls going the wrong way. In the Bitrix data, the flag for incoming calls actually meant outgoing. All 2.8 million calls were backwards until we proved it three independent ways.

  • One owner per call. Bitrix links each call to a single record, so 761,000 calls landed on a contact with no lead, and recordings 404'd from lead timelines. We re-linked 372,448 calls using who actually made them. Heuristics that scored 96 to 97% against ground truth were deliberately not applied: we only wrote recorded facts.

  • Locations that matched nothing. Comparing slugs to display names put 77% of listings on plain "Dubai". Nothing errored and no verification caught it. It ended at zero mismatches.

The first two days

Everything was fast on an empty production table, which turned out to be a latent bug rather than a property.

On go-live day, one slow leads request on a small API task queued everything behind it, and a 361-byte empty response took 43 seconds. We fixed that within the hour with caching and pool limits.

The next morning, the first full working day, median latency hit minutes. The instinct was to add more API tasks. That made it worse: more tasks meant more database connections against a fixed pool. The fix was the opposite. We gave the database four times the CPU and reduced the number of tasks, and the median dropped to 53 milliseconds. The lesson we wrote down that day: the instinct to scale out can be actively wrong.

Later work removed the remaining hot spots. Lead search across 1.6 million rows went from 22.3 seconds to 0.62 per keystroke. Worst-case latency at the top of each hour, when scheduled feeds used to fire together, went from 10.6 seconds to 0.35. Permission checks went from 229 milliseconds to 7.5.

What the business has now

Adoption was immediate because there was no going back: 299 users across 27 sales departments had the leads role from day one, and the share of leads actually assigned to an agent went from 21% in July to 69% in August.

More importantly, the platform does things Bitrix never could:

  • Lead routing on real factors (campaign, developer, location, language), with daily caps and escalation from agent to manager to the leads team.

  • A Lead Pool that hides contact details until a lead is claimed, with claim caps and automatic return.

  • Aging timers defined as data, inside working hours.

  • Attribution across every intake path: our public API, webhooks, Bayut, Meta, TikTok, Snapchat, WhatsApp and bulk imports.

  • Direct Property Finder publishing through its API, replacing XML feeds.

  • In-CRM playback and automatic multilingual transcription for live and archived calls.

  • Field-level permissions, passkeys and an in-house sign-on provider.

  • An Arabic and English interface, and a mobile app whose navigation follows each user's permissions.

  • An AI assistant on the user's own data, and an MCP gateway that lets Claude act in the portal as the real user, with their permissions.

It is less a CRM than an operating system for the company: HR, leave, attendance, assets, expenses and the website CMS live in the same place.

How a small team built it

The platform was built by me and a small team, with AI agents doing a large share of the typing. At peak I ran dozens of Claude Code sessions in parallel, one per feature, with backend, frontend and mobile sessions handing work to each other. The guard rails mattered more than the speed. Agents could only read production through a read-only database role. Every production write was a dry-run-first script that needed an explicit human confirm. Business calls stayed with people, and I ran every production deploy myself.

By mid-October the backend had about 1,000 commits and 550 data models, the web app about 270 pages, and there was a mobile app alongside both.

What I would tell anyone doing this

  1. Rehearse on production-sized data, more than once. Our dev environment was the rehearsal stage, and we treated it that way.

  2. Put a count gate between "loaded" and "imported". Green is not evidence.

  3. Keep the raw source row. You will need a field you did not map.

  4. Map meanings, not labels. Two systems can use the same word for opposite things.

  5. Make the production copy insert-only and approved in phases, with a snapshot first.

  6. Design the delta before the freeze window, or the window will not fit.

  7. Load-test against full tables. Empty tables hide every slow query.

  8. When it is slow, check the database before you add servers.

  9. Let AI agents do the typing, and keep the decisions and the production keys with people.

Bitrix24 is gone now. Nobody at Provident has asked for it back.