You created a job group. Somewhere a user is watching a spinner, and "translating into 14 languages…" is true but useless – it never moves. You want the count to climb in front of them: 3 ready, then 4, then a locale that failed, then done.
Polling the job group gets you there, but it is chatty, and each poll hands you a fresh snapshot you have to diff against the last one to know what actually changed. The WebSocket inverts that. Connect once and the server pushes an event every time a locale resolves – and every message carries the full group state, so you render the snapshot, you never reconcile a delta. Drop a frame, reconnect, restart the tab: the next message is the whole truth again.
GET /jobs/localization/groups/:groupId/wsNew to async localization? Start with the Overview. The groupId here is the one you got back when you created the jobs.
On this page
Message types#
Four message types travel over the socket. Each one tells you what just happened and hands you the current state of the whole group alongside it.
| Type | When | Key fields |
|---|---|---|
snapshot | On initial connection | Full group state |
job.completed | A locale finishes successfully | jobId, locale, plus full group state |
job.failed | A locale fails | jobId, locale, error, plus full group state |
group.completed | Every job has resolved | groupId, status, plus full group state. The server closes the connection after this message. |
Every message contains a snapshot object with the current group state: totalJobs, completedJobs, completedWithWarningsJobs, failedJobs, and a jobs map keyed by job ID, each with its locale and status. Those counts are the same ones the job group endpoint reports – so a snapshot off the socket and a poll off the REST endpoint agree on how far the group has progressed.
render the snapshot, never reconcile
You never need to track which events you have already seen, replay missed messages, or merge a partial update into local state. Read snapshot on every message and paint your UI from it. A reconnect re-sends snapshot first, so a client that just joined and a client that has been listening the whole time converge on the same state.
Message payloads#
These are the exact frames the server sends. The IDs are real shapes (ljg_ for the group, ljb_ for each job); the snapshot is abbreviated with "..." only where it repeats the structure already shown.
On connect, the server sends the current state:
{
"type": "snapshot",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 1,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "processing" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "queued" }
}
}
}As each locale finishes, the event names the locale that changed and includes the updated snapshot:
{
"type": "job.completed",
"jobId": "ljb_B2c3D4e5F6g7H8i9",
"locale": "fr",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 2,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "completed" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "processing" }
}
}
}A failure is a normal message, not a dropped connection. job.failed carries the locale and an error, and the same full snapshot – the failed locale shows status: "failed" in the jobs map, every other locale keeps streaming, and the socket runs on to group.completed:
{
"type": "job.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"locale": "ja",
"error": "Model timeout after 30 seconds",
"snapshot": { "...": "..." }
}When every job has resolved, the server sends a final event and closes the connection:
{
"type": "group.completed",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"status": "completed",
"snapshot": { "...": "..." }
}The terminal status is completed when every locale succeeded, completed_with_warnings when every locale produced output but one or more optional pipeline stages failed on at least one of them, partial when some locales succeeded and some failed, and failed when all of them failed. For what each of those means for the group as a whole, see Track a job group.
Render from snapshot on anything you do not recognize
Switch on the message types you know, and fall through to re-rendering from snapshot on anything you do not recognize. Every message carries a full snapshot, so a client that defaults to painting from it stays correct even on a frame it has no specific branch for.
Wiring it into your UI#
The group is your progress model. When you created the jobs, the 202 handed you a groupId and a jobs array – one entry per locale. Seed your progress record from that response and you have the shape the socket will fill in: the total to count toward, and a counter starting at zero.
const { groupId, jobs } = await response.json();
await db.translationProgress.create({
contentId: content.id,
groupId,
totalLanguages: jobs.length,
completedLanguages: 0,
});Then open the socket against that groupId, and on every message read snapshot and repaint. Watch the counter climb as locales land, and stop when group.completed arrives:
import WebSocket from "ws";
const groupId = "ljg_A1b2C3d4E5f6G7h8";
const ws = new WebSocket(
`wss://api.lingo.dev/jobs/localization/groups/${groupId}/ws`,
{ headers: { "X-API-Key": process.env.LINGO_API_KEY } }
);
ws.on("message", (raw) => {
const event = JSON.parse(raw);
const { snapshot } = event;
switch (event.type) {
case "snapshot":
console.log(`${snapshot.completedJobs}/${snapshot.totalJobs} complete`);
break;
case "job.completed":
console.log(`${event.locale} ready (${snapshot.completedJobs}/${snapshot.totalJobs})`);
break;
case "job.failed":
console.error(`${event.locale} failed: ${event.error}`);
break;
case "group.completed":
console.log(`All translations done: ${event.status}`);
ws.close();
break;
}
});Running against a three-locale group, that prints the run as it happens:
1/3 complete
fr ready (2/3)
ja failed: Model timeout after 30 seconds
All translations done: partialThe counter moved on its own, one locale failed without taking the stream down, and partial told you where the run landed – exactly what your spinner needs to become a real progress bar. Notice the loop never accumulates state: each branch reads from the snapshot on the message in hand, so the same code is correct on first connect, on every update, and on reconnect.
Keep your API key server-side#
The socket authenticates with your API key, the same organization-scoped key the REST endpoints use. That means the browser is the wrong place to open it – an API key in client JavaScript reaches every engine in your organization, for anyone who views source.
Connect from your backend, not the browser
Open the WebSocket from your server, where the key already lives, then fan the events out to the browser over your own channel – a WebSocket or server-sent events stream you control. Your frontend gets live progress; your key never leaves your infrastructure.
This mirrors the webhook model: the connection that touches Lingo.dev is server-side, and what reaches the user is whatever your own app chooses to forward.
Where this fits#
The WebSocket is the live view – it is bound to one group and closes when that group is done. For durable, server-to-server delivery that survives a tab closing or a deploy, pair it with webhooks: the socket drives the UI while the run is on screen, the webhook records each result the moment it lands. Wire both from the same create call and your users see progress as it happens while your backend keeps the output regardless of who is watching.
