Another cron is the cheapest way to say "not in the request". It is often the wrong boundary. A Laravel scheduled command can send the mail, rebuild the report, or poll a partner API, until two runs overlap and a retry repeats a side effect. A Node process beside that app is justified when the work already lives in Node, or when the job's retries and failure mode should not share the web process. It is not a performance fix for a slow query.
Take the decision in that order. What must leave the request. What has to stay true on retry. Only then, which runtime should run it. The situations below are shapes you will recognize. They are not a benchmark of Node against PHP, and they are not a reason to split a small application in two because a diagram showed two boxes.
1. What cron is actually hiding
A scheduled command grows a job inside it. The schedule fires every minute. The command selects rows and does the work inline. While it runs, the next minute fires again. You now have two workers that do not know about each other, sharing a database and whatever external API the command calls.
Cron is already there. Laravel's scheduler is a natural place for "run this later". Later becomes "run this for every tenant, then call a third party, then write a report". The overlap stays invisible until a customer gets two emails or a score is posted twice. A lock around the command stops the overlap. It does not give you a retry policy, a dead letter, or a place to put a run that takes longer than the lock is willing to wait.
Look at the command you already have:
If the honest answers are "one unit of work, safe to run again, longer than a request", you want a queue. The queue can still be Laravel's, on Redis or the database, with a worker you already know how to run. Leaving PHP is a second decision.
2. When the worker should be Node
A split made for taste is expensive. "Node is better at async" is not an architecture. Node is the right worker when the code you need to run is already Node, or when the failure domain should be separate from the Laravel deploy: a long-running consumer, a library that exists only in that ecosystem, a process you can restart without restarting the web pool.
Mixed stacks show up because the web app and the side work did not start as one codebase. On one multi-tenant CRM the product stayed on PHP, and a separate process handled the call pipeline: prompt orchestration, structured model responses, reporting, plus telephony, messaging and accounting. Jobs went through a queue. A retry found the work already recorded and stopped, so it did not create a second result. The boundary was the call pipeline. It was not a claim that Node is faster than a Laravel command. I would not publish a number for "queue versus cron" on that system. The work did not produce that measurement.
On inherited production systems the same caution applies from the other side. Both runtimes may already be in the repository. The decision is what to steady, not a default second process because Node happens to be installed.
Require this before you add the process:
3. Idempotency decides the design more than the runtime
A consumer, in any language, that treats "at least once" as "exactly once" will double a side effect. Brokers deliver again. Processes die after the side effect and before the ack. The second delivery charges, emails or inserts again.
The happy path gets written first. The handler calls the provider, then writes the row. A crash between those lines is a duplicate. Idempotent processing means the second delivery finds the work already recorded and stops. That property mattered on the call pipeline above. It matters the same way if the worker is a Laravel queue on Redis. The language does not provide it. You write the check: a key for this call, this event, this charge, and a result you can read back.
Before you trust a retry, require:
Branch or tenant scope belongs in the same design. On that CRM, each branch had its own database, and migrations and indexes were built for the split. A worker that opens "the" database and trusts the message is how one tenant's job writes another tenant's rows. The queue does not fix that. The message has to carry the tenant, and the worker has to refuse a connection that does not match.
4. Do not add a queue to avoid reading the query
A slow request that gets "fixed" by moving it to Node was slow because of the data access. The worker runs the same queries and the same payload. The user now waits on a spinner labelled "processing", and the CPU bill moves to another service.
A queue is a visible architectural change. An index is not. On one training platform the stack already included Laravel and Node. The measured changes were not a new worker. A loop of database queries became one aggregating query: that operation went from 12.41 seconds to 2.451 seconds, and memory use fell by 1.5 GB. Weekly average CPU load went from 82.2% to 2.75% after the bottlenecks, including missing search indexes, were addressed. A critical operation went from 23,937 ms to 48 ms. One API response went from 912 KB to 2.1 KB. Each figure is that operation, not a platform-wide multiplier. A Node queue beside Laravel would not have been the first change for a twelve-second query loop. The loop would have moved, still taking about twelve seconds, into a process that is harder to see.
Elsewhere, a frozen interface was cleared and a critical process went from about an hour to about six minutes. That result belongs to that process. It is not evidence for a second runtime.
Read the slow operation first. The public version of that habit, for someone who will not open a query plan, is what to time before a redesign. If the slow work is a report rather than a page, the product-owner version is queues and indexes for a crawling CRM report.
5. Three shapes that are not the same decision
Keep the shape attached to the situation. Mixing them is how a story about one product becomes a template you did not earn.
Shape one: a queue beside a PHP application, for work that must retry cleanly. The web app stays where it is. A consumer handles a long pipeline — scoring a call, talking to telephony, writing a report — and the message is the boundary. Copy the boundary and the retry rule, not a mandatory broker.
Shape two: Node is the application, not a sidecar. The product is already a Node service, with its own interface. Performance work is memory leaks and slow queries on that process. Delivery is the build that ships the product to the platforms it actually runs on. There is no Laravel app waiting for a worker. Diagnostics stay on the process that serves the product. Do not invent a second runtime so the diagram has two boxes.
Shape three: a background tool already sits beside the request, sometimes in the same language. An API can use a job runner without a second ecosystem. A search index that shrinks from gigabytes to a few megabytes is an index change. A worker would not have been the change. The question is the same on a small system: what is slow, and does it need another process at all?
When to ask someone to draw the boundary with you
If you can name the job, the retry rule, and the reason the worker is or is not Node, you do not need a second opinion to add a cron lock. Ask when a retry can charge or notify twice and the handler is not idempotent, when a Laravel worker and a Node consumer would both be deployed and nobody owns the message contract, or when a slow query is about to be moved instead of read.
I do this boundary work inside existing Laravel and Node systems, including the case where both are already in production. Services cover performance, SaaS backends and the integration work that usually creates these jobs. Selected work keeps each of those situations in its own scope, without turning any of them into a generic speed-up. If you want a review of one command that has outgrown cron, message me on LinkedIn. Send the job name, what a retry must not repeat, and which runtime the code already lives in.